# ticket_detail_popup.dspy - 工单详情弹窗:正文与操作同屏(复核类弹窗规范) # 页面入口与平台待办共用。入参: 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}] }