ticket/wwwroot/api/ticket_detail_popup.dspy

246 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ticket_detail_popup.dspy - 工单详情弹窗:正文与操作同屏(复核类弹窗规范)
# 页面入口与平台待办共用。入参: id=<ticket_id>
# 视角自适应:客户→确认解决/追问/取消;运维池→认领;受理人→回复客户/转派
import json as _json
user_id = await get_user()
if not user_id:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录后查看待办"}}
ticket_id = ((params_kw or {}).get('id') or '').strip()
PW_ID = 'ticket_todo_detail_pw' if ((params_kw or {}).get('from_todo') or '') == '1' else 'ticket_detail_pw'
if not ticket_id:
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "缺少工单 id"}}
org_id = await get_userorgid()
ok, t = await ticket_detail(ticket_id, user_id, org_id)
if not ok:
code, msg = t
return {"widgettype": "Message", "options": {"title": "打开失败", "message": msg}}
STATUS_LABEL = {
'new': '已提交', 'agent_processing': '智能助手处理中', 'agent_replied': '智能助手已回复',
'human_pending': '待人工认领', 'human_processing': '人工处理中',
'staff_replied': '人工已回复', 'closed': '已关闭', 'cancelled': '已取消',
}
STATUS_COLOR = {
'new': '#64748b', 'agent_processing': '#2563eb', 'agent_replied': '#10b981',
'human_pending': '#f0a040', 'human_processing': '#f0a040',
'staff_replied': '#10b981', 'closed': '#94a3b8', 'cancelled': '#94a3b8',
}
SENDER_LABEL = {'customer': '客户', 'agent': '智能助手', 'staff': '人工客服'}
status = str(t.get('status', ''))
view = str(t.get('view', 'customer'))
# ── 正文 markdown工单信息 + 往来消息流 ──
md_parts = []
md_parts.append('## 工单 ' + str(t.get('ticket_no', '')))
md_parts.append('')
md_parts.append('**标题**' + str(t.get('title', '')))
md_parts.append('**状态**' + STATUS_LABEL.get(status, status)
+ ' **分类**' + str(t.get('category', ''))
+ ' **优先级**' + str(t.get('priority', '')))
if view == 'staff':
md_parts.append('**客户机构**' + str(t.get('customer_org_id', ''))
+ ' **受理角色**' + (str(t.get('assignee_role', '')) or '-')
+ ' **受理人**' + (str(t.get('assignee_id', '')) or '-'))
md_parts.append('')
md_parts.append('---')
md_parts.append('### 往来记录')
for m in (t.get('messages') or []):
who = SENDER_LABEL.get(str(m.get('sender_type', '')), str(m.get('sender_type', '')))
vis = str(m.get('visibility', ''))
tag = '(内部备注)' if vis == 'internal' else ''
md_parts.append('')
md_parts.append('**' + who + tag + '** · ' + str(m.get('created_at', ''))[:19])
md_parts.append('')
md_parts.append(str(m.get('content', '')))
if view == 'staff' and (t.get('transfers') or []):
md_parts.append('')
md_parts.append('---')
md_parts.append('### 流转记录(内部)')
ACTION_LABEL = {'escalate_to_human': '转人工', 'claim': '认领',
'transfer_role': '转角色', 'transfer_user': '转人员'}
for tr in (t.get('transfers') or []):
act = ACTION_LABEL.get(str(tr.get('action', '')), str(tr.get('action', '')))
line = '- ' + str(tr.get('created_at', ''))[:19] + ' ' + act
if tr.get('to_role'):
line += ' → 角色 ' + str(tr.get('to_role'))
if tr.get('to_user'):
line += ' → 人员 ' + str(tr.get('to_user'))
if tr.get('reason'):
line += '' + str(tr.get('reason'))[:80] + ''
md_parts.append(line)
body_md = '\n'.join(md_parts)
# ── 操作按钮 ──
base = entire_url('/ticket/api')
confirm_url = base + '/ticket_confirm.dspy'
followup_url = base + '/ticket_followup.dspy'
cancel_url = base + '/ticket_cancel.dspy'
claim_url = base + '/ticket_claim.dspy'
reply_url = base + '/ticket_staff_reply.dspy'
transfer_url = base + '/ticket_transfer.dspy'
candidates_url = base + '/transfer_candidates.dspy'
_tail = ("if(d.success){"
"var pw=bricks.getWidgetById('" + PW_ID + "',bricks.app);if(pw){pw.destroy();}"
"var lw=bricks.getWidgetById('my_todos_pw',bricks.app);if(lw){lw.destroy();}"
"if(window.refreshTodo){window.refreshTodo();}"
"var mo=new bricks.Message({title:'已处理',message:d.message||'处理成功'});mo.open();"
"}else{var mf=new bricks.Message({title:'处理失败',message:d.error||'处理失败'});mf.open();}")
def _read_input_js(wid):
return ("var cw=bricks.getWidgetById(" + _json.dumps(wid) + ",bricks.app);var cv='';"
"if(cw){cv=(typeof cw.resultValue==='function')?cw.resultValue():"
"((cw.dom_element&&cw.dom_element.value)||'');}"
"cv=(cv===null||cv===undefined)?'':String(cv).trim();")
def _post_js(url, body_expr):
return ("var r=await fetch(" + _json.dumps(url) + ",{method:'POST',"
"headers:{'Content-Type':'application/json'},body:JSON.stringify(" + body_expr + ")});"
"var d=await r.json();" + _tail)
buttons = []
input_widgets = []
if view == 'customer':
if status in ('agent_replied', 'staff_replied'):
buttons.append({
"widgettype": "Button",
"options": {"label": "✔ 确认解决并关闭", "css": "primary"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": _post_js(confirm_url, {"ticket_id": ticket_id})}]})
input_widgets.append({
"widgettype": "UiText", "id": "tk_followup_input",
"options": {"name": "tk_followup_input", "placeholder": "问题没解决?在此输入追问内容…",
"width": "100%", "height": "70px"}})
buttons.append({
"widgettype": "Button",
"options": {"label": "追问", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": (_read_input_js('tk_followup_input')
+ "if(!cv){new bricks.Message({title:'请填写追问内容',message:'追问内容不能为空'}).open();return;}"
+ _post_js(followup_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]})
buttons.append({
"widgettype": "Button",
"options": {"label": "取消工单", "css": "small danger"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": _post_js(cancel_url, {"ticket_id": ticket_id})}]})
elif status in ('new', 'agent_processing'):
buttons.append({
"widgettype": "Button",
"options": {"label": "取消工单", "css": "small danger"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": _post_js(cancel_url, {"ticket_id": ticket_id})}]})
else:
# staff 视角
if status == 'human_pending':
buttons.append({
"widgettype": "Button",
"options": {"label": "认领工单", "css": "primary"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": _post_js(claim_url, {"ticket_id": ticket_id})}]})
elif status == 'human_processing':
input_widgets.append({
"widgettype": "UiText", "id": "tk_reply_input",
"options": {"name": "tk_reply_input", "placeholder": "输入给客户的回复markdown…",
"width": "100%", "height": "90px"}})
buttons.append({
"widgettype": "Button",
"options": {"label": "回复客户", "css": "primary"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": (_read_input_js('tk_reply_input')
+ "if(!cv){new bricks.Message({title:'请填写回复',message:'回复内容不能为空'}).open();return;}"
+ _post_js(reply_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]})
buttons.append({
"widgettype": "Button",
"options": {"label": "记内部备注", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": (_read_input_js('tk_reply_input')
+ "if(!cv){new bricks.Message({title:'请填写备注',message:'备注不能为空'}).open();return;}"
+ _post_js(reply_url, {"ticket_id": ticket_id, "content": "cv_placeholder", "internal": "1"}))}]})
buttons.append({
"widgettype": "Button",
"options": {"label": "转派…", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": ("var r=await fetch(" + _json.dumps(candidates_url) + ");var d=await r.json();"
"if(!d.success){new bricks.Message({title:'加载失败',message:d.error||''}).open();return;}"
"var ropts=(d.data.roles||[]).map(function(x){return {value:x.value,text:'角色:'+x.text};});"
"var uopts=(d.data.users||[]).map(function(x){return {value:'u:'+x.value,text:'人员:'+x.text};});"
"var pw2=bricks.getWidgetById('ticket_transfer_pw',bricks.app);if(pw2){pw2.destroy();}"
"var desc={widgettype:'PopupWindow',id:'ticket_transfer_pw',options:{title:'转派工单',width:'34%',height:'46%',auto_open:true,archor:'cc',movable:true},"
"subwidgets:[{widgettype:'VBox',options:{padding:'14px',gap:'10px'},subwidgets:["
"{widgettype:'UiCode',id:'tk_tr_target',options:{name:'tk_tr_target',placeholder:'选择转派目标',data:ropts.concat(uopts),valueField:'value',textField:'text',width:'100%'}},"
"{widgettype:'UiText',id:'tk_tr_reason',options:{name:'tk_tr_reason',placeholder:'转派原因(必填)',width:'100%',height:'60px'}},"
"{widgettype:'Button',options:{label:'确认转派',css:'primary'},binds:[{wid:'self',event:'click',actiontype:'script',target:'self',script:"
+ _json.dumps(
"var tw=bricks.getWidgetById('tk_tr_target',bricks.app);var tv=tw?(typeof tw.resultValue==='function'?tw.resultValue():''):'';tv=String(tv||'').trim();"
"var rw=bricks.getWidgetById('tk_tr_reason',bricks.app);var rv=rw?(typeof rw.resultValue==='function'?rw.resultValue():((rw.dom_element&&rw.dom_element.value)||'')):'';rv=String(rv||'').trim();"
"if(!tv){new bricks.Message({title:'请选择目标',message:'选择转派角色或人员'}).open();return;}"
"if(!rv){new bricks.Message({title:'请填写原因',message:'转派原因必填'}).open();return;}"
"var body={ticket_id:" + _json.dumps(ticket_id) + ",reason:rv};"
"if(tv.indexOf('u:')===0){body.to_user=tv.substring(2);}else{body.to_role=tv;}"
"var r2=await fetch(" + _json.dumps(transfer_url) + ",{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});"
"var d2=await r2.json();"
"if(d2.success){var p2=bricks.getWidgetById('ticket_transfer_pw',bricks.app);if(p2){p2.destroy();}"
"var pw=bricks.getWidgetById('" + PW_ID + "',bricks.app);if(pw){pw.destroy();}"
"var lw=bricks.getWidgetById('my_todos_pw',bricks.app);if(lw){lw.destroy();}"
"if(window.refreshTodo){window.refreshTodo();}"
"new bricks.Message({title:'已转派',message:d2.message||'转派成功'}).open();}"
"else{new bricks.Message({title:'转派失败',message:d2.error||''}).open();}")
+ "}]}]}}]};"
"bricks.widgetBuild(desc,bricks.app);")}]})
# 追问/回复输入框 content 占位符替换为 JS 变量 cv
for b in buttons:
for bind in (b.get('binds') or []):
if 'cv_placeholder' in str(bind.get('script', '')):
bind['script'] = bind['script'].replace('"cv_placeholder"', 'cv')
sub = [{
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "alignItems": "center"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": STATUS_LABEL.get(status, status), "cfontsize": 0.75,
"color": "#ffffff", "padding": "2px 10px",
"bgcolor": STATUS_COLOR.get(status, '#64748b'),
"borderRadius": "10px", "whiteSpace": "nowrap"}},
{"widgettype": "Text",
"options": {"text": str(t.get('title', '')), "cfontsize": 1.1, "halign": "left"}}
]
}, {
"widgettype": "VBox",
"options": {"width": "100%", "flex": "1 1 auto", "minHeight": "200px",
"border": "1px solid #e2e8f0", "borderRadius": "8px", "overflow": "hidden"},
"subwidgets": [{
"widgettype": "VScrollPanel",
"options": {"css": "filler", "width": "100%", "padding": "6px 12px"},
"subwidgets": [{"widgettype": "MdWidget",
"options": {"mdtext": body_md, "width": "100%"}}]}]
}]
sub.extend(input_widgets)
if buttons:
sub.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "justifyContent": "flex-end"},
"subwidgets": buttons})
return {
"widgettype": "PopupWindow",
"id": PW_ID,
"options": {"title": "工单详情", "width": "72%", "height": "82%",
"auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%",
"padding": "12px 16px", "gap": "10px"},
"subwidgets": sub}]
}