ticket/wwwroot/api/ticket_detail_popup.dspy

430 lines
24 KiB
Plaintext
Raw Permalink 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>
# 视角自适应:客户→确认解决/追问/取消;运维池→认领;受理人→回复客户/转派
# 附件:消息流内联展示(每行 打开/下载,区分上传方)+ 末尾「全部附件」汇总区;
# 追问/回复支持多附件上传FormData multipart落 FileStorage
import json as _json
from urllib.parse import quote as _quote
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': '人工客服'}
# 上传方标识色:客户蓝 / 智能助手紫 / 人工客服橙——一眼区分附件是谁传的
SENDER_COLOR = {'customer': '#2563eb', 'agent': '#8b5cf6', 'staff': '#ea580c'}
status = str(t.get('status', ''))
view = str(t.get('view', 'customer'))
def _fmt_size(n):
try:
n = int(n or 0)
except Exception:
return ''
if n >= 1048576:
return '%.1fMB' % (n / 1048576.0)
if n >= 1024:
return '%.0fKB' % (n / 1024.0)
return '%dB' % n
def _parse_atts(raw):
if not raw:
return []
if isinstance(raw, list):
return [a for a in raw if isinstance(a, dict)]
try:
v = _json.loads(raw)
return [a for a in v if isinstance(a, dict)] if isinstance(v, list) else []
except Exception:
return []
def _att_urls(msg_id, att_id):
base = entire_url('/ticket/api/ticket_attachment.dspy')
open_url = base + '?msg_id=' + _quote(str(msg_id)) + '&att_id=' + _quote(str(att_id))
return open_url, open_url + '&download=1'
def _att_row(sender_type, who_name, fname, size, open_url, dl_url, when=''):
"""单条附件行:上传方徽标 + 📎文件名(大小) + 打开/下载按钮。"""
color = SENDER_COLOR.get(sender_type, '#64748b')
label = SENDER_LABEL.get(sender_type, sender_type)
if who_name:
label = label + '·' + str(who_name)
row = [{
"widgettype": "Text",
"options": {"text": label, "cfontsize": 0.72, "color": "#ffffff",
"bgcolor": color, "padding": "1px 8px", "borderRadius": "8px",
"whiteSpace": "nowrap"}}]
if when:
row.append({"widgettype": "Text",
"options": {"text": str(when)[:16], "cfontsize": 0.72,
"color": "#94a3b8", "whiteSpace": "nowrap"}})
row.append({
"widgettype": "Text",
"options": {"text": "📎 " + str(fname) + ('' + _fmt_size(size) + '' if size else ''),
"cfontsize": 0.85, "color": "#334155", "flex": "1 1 auto",
"overflow": "hidden", "textOverflow": "ellipsis",
"whiteSpace": "nowrap"}})
row.append({
"widgettype": "Button",
"options": {"label": "打开", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": "window.open(" + _json.dumps(open_url) + ",'_blank');"}]})
row.append({
"widgettype": "Button",
"options": {"label": "下载", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": "window.open(" + _json.dumps(dl_url) + ",'_blank');"}]})
return {"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "4px 8px",
"alignItems": "center", "bgcolor": "#f8fafc",
"border": "1px solid #e2e8f0", "borderRadius": "6px"},
"subwidgets": row}
def _msg_widgets(m):
"""一条消息 → [正文MdWidget] + [附件行...]。"""
st = str(m.get('sender_type', ''))
who = SENDER_LABEL.get(st, st)
nick = str(m.get('sender_nick', '') or m.get('sender_name', '') or '')
if nick:
who = who + '·' + nick
vis = str(m.get('visibility', ''))
tag = '(内部备注)' if vis == 'internal' else ''
parts = ['', '**' + who + tag + '** · ' + str(m.get('created_at', ''))[:19],
'', str(m.get('content', ''))]
ws = [{"widgettype": "MdWidget",
"options": {"mdtext": '\n'.join(parts), "width": "100%"}}]
for a in _parse_atts(m.get('attachments')):
ou, du = _att_urls(m.get('id', ''), a.get('id', ''))
ws.append(_att_row(st, nick, a.get('name', ''), a.get('size', 0), ou, du))
return ws
# ── 正文区 widgets工单信息 + 往来消息流(含内联附件)+ 全部附件汇总 + 流转记录 ──
head_md = ['## 工单 ' + str(t.get('ticket_no', '')), '',
'**标题**' + str(t.get('title', '')),
'**状态**' + STATUS_LABEL.get(status, status)
+ ' **分类**' + str(t.get('category', ''))
+ ' **优先级**' + str(t.get('priority', ''))]
if view == 'staff':
head_md.append('**客户机构**' + str(t.get('customer_org_id', ''))
+ ' **受理角色**' + (str(t.get('assignee_role', '')) or '-')
+ ' **受理人**' + (str(t.get('assignee_id', '')) or '-'))
head_md += ['', '---', '### 往来记录']
body_widgets = [{"widgettype": "MdWidget",
"options": {"mdtext": '\n'.join(head_md), "width": "100%"}}]
messages = t.get('messages') or []
for m in messages:
body_widgets.extend(_msg_widgets(m))
# 全部附件汇总区:跨消息聚合,区分上传方,可直接打开/下载
_all_atts = []
for m in messages:
for a in _parse_atts(m.get('attachments')):
_all_atts.append((m, a))
if _all_atts:
body_widgets.append({"widgettype": "MdWidget",
"options": {"mdtext": '\n'.join(['', '---',
'### 全部附件(%d 个)' % len(_all_atts)]),
"width": "100%"}})
for m, a in _all_atts:
nick = str(m.get('sender_nick', '') or m.get('sender_name', '') or '')
ou, du = _att_urls(m.get('id', ''), a.get('id', ''))
body_widgets.append(_att_row(str(m.get('sender_type', '')), nick,
a.get('name', ''), a.get('size', 0), ou, du,
when=m.get('created_at', '')))
if view == 'staff' and (t.get('transfers') or []):
tr_md = ['', '---', '### 流转记录(内部)']
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] + ''
tr_md.append(line)
body_widgets.append({"widgettype": "MdWidget",
"options": {"mdtext": '\n'.join(tr_md), "width": "100%"}})
# ── 操作按钮 ──
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 _read_files_js(wid):
"""读 UiFilemultiple选中的 File 数组 → JS 变量 _fs并做数量/总量前端拦截。
总量 9.5MBpipeline-app client_max_size=10MB超了请求会被拒/挂起,必须前端拦。"""
return ("var _fw=bricks.getWidgetById(" + _json.dumps(wid) + ",bricks.app);var _fs=[];"
"if(_fw&&typeof _fw.resultValue==='function'){var _fv=_fw.resultValue();"
"if(_fv){_fs=Array.isArray(_fv)?_fv:[_fv];}}"
"var _tot=0;for(var _ti=0;_ti<_fs.length;_ti++){_tot+=(_fs[_ti].size||0);}"
"if(_fs.length>9){new bricks.Message({title:'附件过多',"
"message:'单条消息最多上传 9 个附件'}).open();return;}"
"if(_tot>9.5*1024*1024){new bricks.Message({title:'附件过大',"
"message:'单次提交附件总大小不能超过 9.5MB'}).open();return;}")
def _post_js(url, body_expr):
# body_expr 为 dict 时序列化为 JS 对象字面量2026-09-10 修复:原实现直接
# 字符串拼接 dict → TypeError 500所有带按钮的弹窗路径全炸此前 staff_replied
# 态不进任何按钮分支才掩盖了此bug。占位符 "cv_placeholder" 后续替换为 JS 变量 cv。
if isinstance(body_expr, dict):
body_js = _json.dumps(body_expr)
else:
body_js = str(body_expr)
return ("var r=await fetch(" + _json.dumps(url) + ",{method:'POST',"
"headers:{'Content-Type':'application/json'},body:JSON.stringify(" + body_js + ")});"
"var d=await r.json();" + _tail)
def _post_multipart_js(url, tid, text_var='_fs', extra=None):
"""FormData multipart 提交(带附件)。文本字段 content 取 JS 变量 cv
附件字段 attachments 逐个 append Fileextra: dict 附加表单字段。
不设 Content-Type——浏览器自动带 multipart boundary。"""
js = ("var fd=new FormData();"
"fd.append('ticket_id'," + _json.dumps(tid) + ");"
"fd.append('content',cv);")
for k, v in (extra or {}).items():
js += "fd.append(" + _json.dumps(k) + "," + _json.dumps(v) + ");"
js += ("for(var _fi=0;_fi<" + text_var + ".length;_fi++){"
"fd.append('attachments'," + text_var + "[_fi]);}"
"var r=await fetch(" + _json.dumps(url) + ",{method:'POST',body:fd});"
"var d=await r.json();" + _tail)
return js
def _upload_box(wid, tip):
return {
"widgettype": "VBox",
"options": {"width": "100%", "gap": "2px"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": tip, "cfontsize": 0.72, "color": "#94a3b8"}},
{"widgettype": "UiFile", "id": wid,
"options": {"name": wid, "width": "100%", "multiple": True,
"preview": True}}]}
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"}})
input_widgets.append(_upload_box('tk_followup_files',
'追问附件(可选):最多 9 个,总大小 ≤9.5MB,可拖入或点击选择'))
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;}"
+ _read_files_js('tk_followup_files')
+ _post_multipart_js(followup_url, ticket_id))}]})
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"}})
input_widgets.append(_upload_box('tk_reply_files',
'回复附件(可选):最多 9 个,总大小 ≤9.5MB,可拖入或点击选择'))
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;}"
+ _read_files_js('tk_reply_files')
+ _post_multipart_js(reply_url, ticket_id))}]})
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;}"
+ _read_files_js('tk_reply_files')
+ _post_multipart_js(reply_url, ticket_id,
extra={"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"}})
input_widgets.append(_upload_box('tk_followup_files',
'追问附件(可选):最多 9 个,总大小 ≤9.5MB,可拖入或点击选择'))
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;}"
+ _read_files_js('tk_followup_files')
+ _post_multipart_js(followup_url, ticket_id))}]})
# 追问/回复输入框 content 占位符替换为 JS 变量 cvJSON 通道遗留兼容)
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": body_widgets}]
}]
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}]
}