ticket/wwwroot/api/ticket_detail_popup.dspy
yumoqing 54f2f2d597 feat: 工单管理模块初版——客户提问→agent预处理→人工运维→转派,接入平台待办
- 3表: tk_tickets(状态机+受理角色/人+agent锁)/tk_messages(客户可见/内部备注)/tk_transfers(append-only流水)
- core.py: 8态13迁移状态机全乐观锁; 转派候选动态查owner角色+'0'机构用户(不硬编码);
  默认受理角色params可配(ticket_default_role=owner.maintainer)
- agent.py: 异步poller(bid_flow范式), RAG检索'0'机构KB+历史工单, llm_call(org_id=0)
  LLM裁决能否回答(严格JSON), 硬门禁reply<20字符转人工, 故障≠答不了(fail_rounds>=3转人工),
  stale回收10min; 追问回流(agent_processing+owner空)立即拾取不等stale
- todos.py: 平台待办provider, 状态派生(客户待确认/角色池待认领/受理人待处理)
- init.py: load_ticket注册env+provider+poller(PIPELINE_MODE=web不启动)
- wwwroot: 客户我的工单页+建单表单, 运维工单管理页(队列切换), 详情弹窗(正文与操作同屏,按视角出按钮)
- load_path.py: logined全部API(业务权限服务端按current_role动态校验)+管理页壳owner三角色
- init/data.json: 7组码表+owner.maintainer角色种子(app_audit先例)
2026-09-10 16:17:58 +08:00

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('current_role', '')) or '-')
+ ' | **受理人**:' + (str(t.get('current_assignee', '')) 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}]
}