ticket/wwwroot/api/ticket_detail_popup.dspy
yumoqing 9c36ab38d2 fix: 工单管理查不到+待办清不掉 四个叠加缺陷根治(2026-09-10用户报障)
报障现象: owner端工单管理查不到任何工单, 但有个工单待办; 待办弹窗无处理按钮致待办数不变。

根因链(全部有DB/代码证据):
1. 弹窗缺客户动作按钮: 双身份用户(admin既是TK0004客户又是staff), staff视角在
   staff_replied态一个按钮都没有, 而此刻待办要求的动作是客户确认——待办永远清不掉。
   修: staff视角下当前用户=工单客户且状态在已回复态时追加客户动作按钮。
2. 管理页默认队列=待认领, 当时无待认领单+前端首次查询点击渲染时序中间态→看似查不到。
   修: 默认队列改全部工单。
3. T12转派校验不全: 只查目标用户属于0机构不查owner角色, 测试时把TK0003转给无角色的hyz1
   产生孤儿工单(角色池无人可认领+hyz1打开管理页被权限分支挡)。
   修: 目标用户必须持owner角色否则TK_E004拒绝; 候选端点只列持owner角色的用户;
   孤儿工单TK0003数据修复退回角色池。
4. staff_tickets的all分支权限过窄: 无owner角色的账号直接返回空, 受理人看不到自己手上的工单。
   修: 无owner角色账号至少能看到受理人是自己的工单。
2026-09-10 17:38:31 +08:00

269 lines
16 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);")}]})
# 双身份用户(既是工单客户又是staff,如平台管理员自建工单):
# staff 视角在 agent_replied/staff_replied 态无任何按钮,但此刻待办要求的动作是
# 「客户确认/追问」——缺按钮导致待办永远清不掉(2026-09-10 用户报障)。
# 补:staff 视角下若当前用户就是该工单客户,追加客户动作按钮。
if view == 'staff' and str(t.get('customer_user_id', '')) == str(user_id) \
and 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"}))}]})
# 追问/回复输入框 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}]
}