feat(attachments): 工单全链路附件——客户建单/追问、运维回复/内部备注支持多附件上传(multipart→FileStorage),详情弹窗(页面+待办共用)消息流内联附件行+全部附件汇总区,上传方彩色徽标区分(客户/助手/客服),打开/下载走ticket_attachment.dspy(I5服务端可见性校验+防路径穿越),服务端normalize_attachments规整+file_useful持久化豁免,错误码TK_E014/E015
This commit is contained in:
parent
e613bc860f
commit
4aa01d4d79
@ -42,6 +42,8 @@ PATHS_LOGINED = [
|
||||
f"/{MOD}/api/ticket_followup.dspy",
|
||||
f"/{MOD}/api/ticket_confirm.dspy",
|
||||
f"/{MOD}/api/ticket_cancel.dspy",
|
||||
# 附件打开/下载(服务端 get_attachment_file 按 I5 可见性校验)
|
||||
f"/{MOD}/api/ticket_attachment.dspy",
|
||||
# staff 侧 API(服务端 _check_staff_access 按工单 assignee_role 动态校验)
|
||||
f"/{MOD}/api/staff_tickets.dspy",
|
||||
f"/{MOD}/api/ticket_claim.dspy",
|
||||
|
||||
163
ticket/core.py
163
ticket/core.py
@ -15,6 +15,7 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from appPublic.uniqueID import getID
|
||||
from sqlor.dbpools import DBPools
|
||||
@ -26,6 +27,9 @@ DBNAME_FALLBACK = 'pipeline'
|
||||
|
||||
AGENT_USER = 'agent.ticket'
|
||||
|
||||
# 附件约束:单消息最多 9 个(前端同步拦截;client_max_size=100MB 兜底总量)
|
||||
MAX_ATTACHMENTS = 9
|
||||
|
||||
# ── 状态集 ──
|
||||
S_NEW = 'new'
|
||||
S_AGENT_PROCESSING = 'agent_processing'
|
||||
@ -132,6 +136,132 @@ async def _add_message(sor, ticket_id, sender_type, sender_id, sender_role,
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
|
||||
|
||||
# ══════════════════ 附件(FileStorage/idfile 机制,设计文档 §8) ══════════════════
|
||||
|
||||
def _filestorage():
|
||||
from ahserver.filestorage import FileStorage
|
||||
return FileStorage()
|
||||
|
||||
|
||||
def normalize_attachments(raw):
|
||||
"""把上传附件规整为 [{id,name,webpath,size}](服务端生成,不信任客户端)。
|
||||
|
||||
raw 形态:str(单个 webpath 或 JSON 数组字符串)/ list[webpath|dict]
|
||||
(multipart 多文件同名字段被 ahserver getPostData 聚合为 list)/ dict / None。
|
||||
校验:webpath 必须是 FileStorage 落盘的相对路径('/' 开头、无 '..')、
|
||||
真实存在、不越出 filesroot;并调 tfr.file_useful 把文件从临时清理队列摘除
|
||||
(multipart 上传默认 1 小时后被当临时文件删掉——工单附件必须持久)。
|
||||
返回 (items, err);err 为 (code,msg) 或 None。
|
||||
"""
|
||||
if not raw:
|
||||
return [], None
|
||||
if isinstance(raw, (str, dict)):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, list):
|
||||
return [], ('TK_E014', '附件参数格式无效')
|
||||
# 兼容旧前端契约:JSON 数组字符串 '["/xx/a.png"]' 或 '[{"webpath":...}]'
|
||||
if len(raw) == 1 and isinstance(raw[0], str) and raw[0].strip().startswith('['):
|
||||
try:
|
||||
parsed = json.loads(raw[0])
|
||||
if isinstance(parsed, list):
|
||||
raw = parsed
|
||||
except Exception:
|
||||
pass
|
||||
if len(raw) > MAX_ATTACHMENTS:
|
||||
return [], ('TK_E014', '单条消息最多上传 %d 个附件' % MAX_ATTACHMENTS)
|
||||
items = []
|
||||
fs = None
|
||||
for it in raw:
|
||||
if isinstance(it, dict):
|
||||
wp = str(it.get('webpath') or it.get('path') or '').strip()
|
||||
name = str(it.get('name') or '').strip()
|
||||
else:
|
||||
wp = str(it or '').strip()
|
||||
name = ''
|
||||
if not wp:
|
||||
continue
|
||||
if not wp.startswith('/') or '..' in wp:
|
||||
return [], ('TK_E014', '附件路径无效:%s' % wp[:80])
|
||||
try:
|
||||
if fs is None:
|
||||
fs = _filestorage()
|
||||
real = fs.realPath(wp)
|
||||
root = os.path.realpath(fs.root)
|
||||
if not os.path.realpath(real).startswith(root + os.sep) \
|
||||
or not os.path.isfile(real):
|
||||
return [], ('TK_E014', '附件文件不存在或路径非法:%s' % os.path.basename(wp)[:80])
|
||||
size = os.path.getsize(real)
|
||||
try:
|
||||
fs.tfr.file_useful(wp) # 持久化:从临时文件清理队列摘除
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
return [], ('TK_E014', '附件校验失败:%s' % str(e)[:100])
|
||||
if not name:
|
||||
name = os.path.basename(wp)
|
||||
items.append({'id': getID(), 'name': name[:200],
|
||||
'webpath': wp, 'size': int(size)})
|
||||
return items, None
|
||||
|
||||
|
||||
def parse_attachments(raw):
|
||||
"""tk_messages.attachments 列(JSON 字符串)→ list;容错返回 []。"""
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
try:
|
||||
v = json.loads(raw)
|
||||
return v if isinstance(v, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def get_attachment_file(msg_id, att_id, user_id, org_id):
|
||||
"""附件打开/下载:按消息+附件 id 取文件,I5 可见性校验。
|
||||
|
||||
客户只能取 visibility='customer' 消息的附件;staff/admin 全量。
|
||||
返回 (True, (realpath, filename)) 或 (False, (code, msg))。
|
||||
"""
|
||||
db = _get_db()
|
||||
async with db.sqlorContext(_dbname()) as sor:
|
||||
roles = await get_user_roles(sor, user_id)
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, ticket_id, attachments, visibility FROM tk_messages WHERE id=${i}$",
|
||||
{"i": str(msg_id or '')})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
return False, E_NOT_FOUND
|
||||
m = recs[0]
|
||||
t = await _load_ticket(sor, str(getattr(m, 'ticket_id', '') or ''))
|
||||
if not t:
|
||||
return False, E_NOT_FOUND
|
||||
is_staff_view = await _check_staff_access(sor, t, user_id, roles) or _is_admin(roles)
|
||||
if not is_staff_view:
|
||||
if not await _check_customer_access(sor, t, user_id, org_id, roles):
|
||||
return False, E_NOT_FOUND
|
||||
if str(getattr(m, 'visibility', '')) != 'customer':
|
||||
return False, E_NOT_FOUND
|
||||
atts = parse_attachments(getattr(m, 'attachments', ''))
|
||||
target = None
|
||||
for a in atts:
|
||||
if isinstance(a, dict) and str(a.get('id', '')) == str(att_id or ''):
|
||||
target = a
|
||||
break
|
||||
if not target:
|
||||
return False, ('TK_E015', '附件不存在')
|
||||
wp = str(target.get('webpath', '') or '')
|
||||
if not wp.startswith('/') or '..' in wp:
|
||||
return False, ('TK_E014', '附件路径无效')
|
||||
fs = _filestorage()
|
||||
real = fs.realPath(wp)
|
||||
root = os.path.realpath(fs.root)
|
||||
if not os.path.realpath(real).startswith(root + os.sep) or not os.path.isfile(real):
|
||||
return False, ('TK_E015', '附件文件已丢失,请联系平台运维')
|
||||
fname = str(target.get('name', '') or os.path.basename(wp))
|
||||
return True, (real, fname)
|
||||
|
||||
|
||||
async def _add_transfer(sor, ticket_id, action, from_role='', from_user='',
|
||||
to_role='', to_user='', reason='', operator_id=''):
|
||||
await sor.C('tk_transfers', {
|
||||
@ -189,7 +319,11 @@ def _rec_to_dict(rec):
|
||||
|
||||
async def create_ticket(user_id, org_id, title, description, category='other',
|
||||
priority='normal', attachments=None):
|
||||
"""R1 客户建单。返回 (True, ticket dict) 或 (False, (code, msg))。"""
|
||||
"""R1 客户建单。返回 (True, ticket dict) 或 (False, (code, msg))。
|
||||
|
||||
attachments: 客户端上传后的 FileStorage webpath(str 或 list),
|
||||
服务端 normalize_attachments 校验+规整为 [{id,name,webpath,size}]。
|
||||
"""
|
||||
title = (title or '').strip()
|
||||
description = (description or '').strip()
|
||||
if not title:
|
||||
@ -198,6 +332,9 @@ async def create_ticket(user_id, org_id, title, description, category='other',
|
||||
return False, ('TK_E011', '请填写问题描述')
|
||||
if not user_id:
|
||||
return False, ('TK_E012', '请先登录')
|
||||
atts, att_err = normalize_attachments(attachments)
|
||||
if att_err:
|
||||
return False, att_err
|
||||
|
||||
db = _get_db()
|
||||
async with db.sqlorContext(_dbname()) as sor:
|
||||
@ -237,7 +374,7 @@ async def create_ticket(user_id, org_id, title, description, category='other',
|
||||
return False, ('TK_E013', '工单编号生成冲突,请重试')
|
||||
|
||||
await _add_message(sor, tid, 'customer', user_id, '', description,
|
||||
attachments=attachments)
|
||||
attachments=atts or None)
|
||||
logger.info("ticket created: %s (%s) by %s", tid, no, user_id)
|
||||
return True, {'id': tid, 'ticket_no': no, 'status': S_NEW}
|
||||
|
||||
@ -250,12 +387,15 @@ async def _check_customer_access(sor, t, user_id, org_id, roles):
|
||||
(org_id and str(getattr(t, 'customer_org_id', '')) == str(org_id))
|
||||
|
||||
|
||||
async def ticket_followup(ticket_id, user_id, org_id, content):
|
||||
async def ticket_followup(ticket_id, user_id, org_id, content, attachments=None):
|
||||
"""T6/T10 客户追问。agent_replied→agent_processing(超限转人工);
|
||||
staff_replied→human_processing(回当前受理人)。"""
|
||||
staff_replied→human_processing(回当前受理人)。可带附件。"""
|
||||
content = (content or '').strip()
|
||||
if not content:
|
||||
return False, ('TK_E010', '请填写追问内容')
|
||||
atts, att_err = normalize_attachments(attachments)
|
||||
if att_err:
|
||||
return False, att_err
|
||||
db = _get_db()
|
||||
async with db.sqlorContext(_dbname()) as sor:
|
||||
roles = await get_user_roles(sor, user_id)
|
||||
@ -266,7 +406,8 @@ async def ticket_followup(ticket_id, user_id, org_id, content):
|
||||
if status not in (S_AGENT_REPLIED, S_STAFF_REPLIED):
|
||||
return False, ('TK_E002', '工单当前状态为 %s,不能追问(需状态 agent_replied/staff_replied)' % status)
|
||||
|
||||
await _add_message(sor, ticket_id, 'customer', user_id, '', content)
|
||||
await _add_message(sor, ticket_id, 'customer', user_id, '', content,
|
||||
attachments=atts or None)
|
||||
|
||||
if status == S_STAFF_REPLIED:
|
||||
# T10:回当前受理人(human_processing,assignee 不变)
|
||||
@ -380,11 +521,16 @@ async def ticket_claim(ticket_id, user_id):
|
||||
return True, {'status': S_HUMAN_PROCESSING, 'message': '认领成功'}
|
||||
|
||||
|
||||
async def ticket_staff_reply(ticket_id, user_id, content, internal=False):
|
||||
"""T8 人工回复:human_processing → staff_replied。internal=True 只记内部备注不迁状态。"""
|
||||
async def ticket_staff_reply(ticket_id, user_id, content, internal=False,
|
||||
attachments=None):
|
||||
"""T8 人工回复:human_processing → staff_replied。internal=True 只记内部备注不迁状态。
|
||||
可带附件(客户可见回复与内部备注均可)。"""
|
||||
content = (content or '').strip()
|
||||
if not content:
|
||||
return False, ('TK_E010', '请填写回复内容')
|
||||
atts, att_err = normalize_attachments(attachments)
|
||||
if att_err:
|
||||
return False, att_err
|
||||
db = _get_db()
|
||||
async with db.sqlorContext(_dbname()) as sor:
|
||||
roles = await get_user_roles(sor, user_id)
|
||||
@ -400,7 +546,8 @@ async def ticket_staff_reply(ticket_id, user_id, content, internal=False):
|
||||
|
||||
sender_role = cur_assignee and _role_of(roles) or ''
|
||||
await _add_message(sor, ticket_id, 'staff', user_id, sender_role, content,
|
||||
visibility='internal' if internal else 'customer')
|
||||
visibility='internal' if internal else 'customer',
|
||||
attachments=atts or None)
|
||||
if internal:
|
||||
return True, {'status': status, 'message': '内部备注已记录'}
|
||||
ok = await _cas_status(sor, ticket_id, S_HUMAN_PROCESSING,
|
||||
|
||||
@ -26,6 +26,7 @@ def load_ticket():
|
||||
env.list_staff_tickets = core.list_staff_tickets
|
||||
env.ticket_detail = core.ticket_detail
|
||||
env.ticket_get_user_roles = core.get_user_roles
|
||||
env.ticket_get_attachment_file = core.get_attachment_file
|
||||
|
||||
# 平台待办 provider(软注册:pipeline-service 没装/没钩子时只告警不崩)
|
||||
try:
|
||||
|
||||
32
wwwroot/api/ticket_attachment.dspy
Normal file
32
wwwroot/api/ticket_attachment.dspy
Normal file
@ -0,0 +1,32 @@
|
||||
# ticket_attachment.dspy - 工单附件打开/下载(I5 可见性服务端校验)
|
||||
# 入参: msg_id=<消息id>, att_id=<附件id>, download=1 时以附件名下载
|
||||
# 客户只能取 visibility='customer' 消息附件;staff/admin 全量。
|
||||
|
||||
from urllib.parse import quote as _quote
|
||||
from aiohttp.web_fileresponse import FileResponse
|
||||
|
||||
user_id = await get_user()
|
||||
if not user_id:
|
||||
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
|
||||
|
||||
msg_id = ((params_kw or {}).get('msg_id') or '').strip()
|
||||
att_id = ((params_kw or {}).get('att_id') or '').strip()
|
||||
download = ((params_kw or {}).get('download') or '').strip()
|
||||
|
||||
ok, res = await ticket_get_attachment_file(msg_id, att_id, user_id, await get_userorgid())
|
||||
if not ok:
|
||||
code, msg = res
|
||||
return {"widgettype": "Message", "options": {"title": "打开失败", "message": msg}}
|
||||
|
||||
full_path, fname = res
|
||||
headers = {}
|
||||
if download:
|
||||
safe_name = _quote(str(fname))
|
||||
headers['Content-Disposition'] = (
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (fname, safe_name))
|
||||
else:
|
||||
# 图片/PDF 等浏览器可直接渲染的类型内联打开
|
||||
headers['Content-Disposition'] = (
|
||||
'inline; filename="%s"; filename*=UTF-8\'\'%s' % (fname, safe_name))
|
||||
|
||||
return FileResponse(full_path, headers=headers)
|
||||
@ -1,5 +1,5 @@
|
||||
# ticket_create.dspy - 客户建单(R1)
|
||||
# 入参: title, description, category?, priority?, attachments?(JSON数组字符串)
|
||||
# 入参: title, description, category?, priority?, attachments?(multipart 文件字段,可多个)
|
||||
|
||||
user_id = await get_user()
|
||||
if not user_id:
|
||||
@ -11,15 +11,10 @@ description = (params_kw.get('description') or '').strip()
|
||||
category = (params_kw.get('category') or 'other').strip()
|
||||
priority = (params_kw.get('priority') or 'normal').strip()
|
||||
|
||||
attachments = None
|
||||
att_raw = (params_kw.get('attachments') or '').strip()
|
||||
if att_raw:
|
||||
try:
|
||||
attachments = json.loads(att_raw)
|
||||
if not isinstance(attachments, list):
|
||||
attachments = None
|
||||
except Exception:
|
||||
attachments = None
|
||||
# 附件:Form(uitype=file,multiple) 走 FormData multipart,ahserver 已把文件落
|
||||
# FileStorage,params_kw['attachments'] 为 webpath 字符串(单文件)或 list(多文件)。
|
||||
# 旧 JSON 数组字符串契约由 core.normalize_attachments 兼容。
|
||||
attachments = params_kw.get('attachments')
|
||||
|
||||
ok, result = await create_ticket(user_id, org_id, title, description,
|
||||
category=category, priority=priority,
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
# 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:
|
||||
@ -30,37 +33,137 @@ STATUS_COLOR = {
|
||||
'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'))
|
||||
|
||||
# ── 正文 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', '')))
|
||||
|
||||
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 ''
|
||||
md_parts.append('')
|
||||
md_parts.append('**' + who + tag + '** · ' + str(m.get('created_at', ''))[:19])
|
||||
md_parts.append('')
|
||||
md_parts.append(str(m.get('content', '')))
|
||||
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 []):
|
||||
md_parts.append('')
|
||||
md_parts.append('---')
|
||||
md_parts.append('### 流转记录(内部)')
|
||||
tr_md = ['', '---', '### 流转记录(内部)']
|
||||
ACTION_LABEL = {'escalate_to_human': '转人工', 'claim': '认领',
|
||||
'transfer_role': '转角色', 'transfer_user': '转人员'}
|
||||
for tr in (t.get('transfers') or []):
|
||||
@ -72,8 +175,9 @@ if view == 'staff' and (t.get('transfers') or []):
|
||||
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)
|
||||
tr_md.append(line)
|
||||
body_widgets.append({"widgettype": "MdWidget",
|
||||
"options": {"mdtext": '\n'.join(tr_md), "width": "100%"}})
|
||||
|
||||
# ── 操作按钮 ──
|
||||
base = entire_url('/ticket/api')
|
||||
@ -100,6 +204,19 @@ def _read_input_js(wid):
|
||||
"cv=(cv===null||cv===undefined)?'':String(cv).trim();")
|
||||
|
||||
|
||||
def _read_files_js(wid):
|
||||
"""读 UiFile(multiple)选中的 File 数组 → JS 变量 _fs,并做数量/总量前端拦截。
|
||||
总量 9.5MB:pipeline-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
|
||||
@ -113,6 +230,34 @@ def _post_js(url, body_expr):
|
||||
"var d=await r.json();" + _tail)
|
||||
|
||||
|
||||
def _post_multipart_js(url, tid, text_var='_fs', extra=None):
|
||||
"""FormData multipart 提交(带附件)。文本字段 content 取 JS 变量 cv;
|
||||
附件字段 attachments 逐个 append File;extra: 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 = []
|
||||
|
||||
@ -127,13 +272,16 @@ if view == 'customer':
|
||||
"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;}"
|
||||
+ _post_js(followup_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]})
|
||||
+ _read_files_js('tk_followup_files')
|
||||
+ _post_multipart_js(followup_url, ticket_id))}]})
|
||||
buttons.append({
|
||||
"widgettype": "Button",
|
||||
"options": {"label": "取消工单", "css": "small danger"},
|
||||
@ -158,20 +306,25 @@ else:
|
||||
"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;}"
|
||||
+ _post_js(reply_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]})
|
||||
+ _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;}"
|
||||
+ _post_js(reply_url, {"ticket_id": ticket_id, "content": "cv_placeholder", "internal": "1"}))}]})
|
||||
+ _read_files_js('tk_reply_files')
|
||||
+ _post_multipart_js(reply_url, ticket_id,
|
||||
extra={"internal": "1"}))}]})
|
||||
buttons.append({
|
||||
"widgettype": "Button",
|
||||
"options": {"label": "转派…", "css": "small"},
|
||||
@ -201,8 +354,7 @@ else:
|
||||
"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);")}]})
|
||||
+ "}]}]}}]};bricks.widgetBuild(desc,bricks.app);")}]})
|
||||
|
||||
# 双身份用户(既是工单客户又是staff,如平台管理员自建工单):
|
||||
# staff 视角在 agent_replied/staff_replied 态无任何按钮,但此刻待办要求的动作是
|
||||
@ -219,15 +371,18 @@ if view == 'staff' and str(t.get('customer_user_id', '')) == str(user_id) \
|
||||
"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;}"
|
||||
+ _post_js(followup_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]})
|
||||
+ _read_files_js('tk_followup_files')
|
||||
+ _post_multipart_js(followup_url, ticket_id))}]})
|
||||
|
||||
# 追问/回复输入框 content 占位符替换为 JS 变量 cv
|
||||
# 追问/回复输入框 content 占位符替换为 JS 变量 cv(JSON 通道遗留兼容)
|
||||
for b in buttons:
|
||||
for bind in (b.get('binds') or []):
|
||||
if 'cv_placeholder' in str(bind.get('script', '')):
|
||||
@ -252,8 +407,7 @@ sub = [{
|
||||
"subwidgets": [{
|
||||
"widgettype": "VScrollPanel",
|
||||
"options": {"css": "filler", "width": "100%", "padding": "6px 12px"},
|
||||
"subwidgets": [{"widgettype": "MdWidget",
|
||||
"options": {"mdtext": body_md, "width": "100%"}}]}]
|
||||
"subwidgets": body_widgets}]
|
||||
}]
|
||||
sub.extend(input_widgets)
|
||||
if buttons:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# ticket_followup.dspy - 客户追问(T6/T10: agent_replied→agent处理; staff_replied→回受理人)
|
||||
# 入参: ticket_id, content
|
||||
# 入参: ticket_id, content, attachments?(multipart 文件字段,可多个)
|
||||
|
||||
user_id = await get_user()
|
||||
if not user_id:
|
||||
@ -10,8 +10,13 @@ content = (params_kw.get('content') or '').strip()
|
||||
if not ticket_id:
|
||||
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
|
||||
|
||||
# 附件:multipart 上传时 ahserver 已落 FileStorage,params_kw['attachments']
|
||||
# 为 webpath 字符串(单文件)或 list(多文件同名字段);JSON 通道传数组字符串也兼容
|
||||
attachments = params_kw.get('attachments')
|
||||
|
||||
org_id = await get_userorgid()
|
||||
ok, result = await ticket_followup(ticket_id, user_id, org_id, content)
|
||||
ok, result = await ticket_followup(ticket_id, user_id, org_id, content,
|
||||
attachments=attachments)
|
||||
if ok:
|
||||
return json.dumps({"success": True, "message": result.get('message', ''),
|
||||
"data": result}, ensure_ascii=False)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# ticket_staff_reply.dspy - 人工回复客户(T8: human_processing→staff_replied)
|
||||
# 入参: ticket_id, content, internal?(1=内部备注不迁状态)
|
||||
# 入参: ticket_id, content, internal?(1=内部备注不迁状态), attachments?(multipart 文件字段,可多个)
|
||||
|
||||
user_id = await get_user()
|
||||
if not user_id:
|
||||
@ -11,7 +11,12 @@ internal = (params_kw.get('internal') or '').strip().lower() in ('1', 'true', 'y
|
||||
if not ticket_id:
|
||||
return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False)
|
||||
|
||||
ok, result = await ticket_staff_reply(ticket_id, user_id, content, internal=internal)
|
||||
# 附件:multipart 上传时 ahserver 已落 FileStorage,params_kw['attachments']
|
||||
# 为 webpath 字符串(单文件)或 list(多文件同名字段)
|
||||
attachments = params_kw.get('attachments')
|
||||
|
||||
ok, result = await ticket_staff_reply(ticket_id, user_id, content, internal=internal,
|
||||
attachments=attachments)
|
||||
if ok:
|
||||
return json.dumps({"success": True, "message": result.get('message', ''),
|
||||
"data": result}, ensure_ascii=False)
|
||||
|
||||
@ -13,10 +13,13 @@
|
||||
{"name": "category", "label": "问题分类", "uitype": "code", "value": "consult",
|
||||
"data": [{"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"}, {"value": "billing", "text": "计费账务"}, {"value": "other", "text": "其他"}]},
|
||||
{"name": "priority", "label": "优先级", "uitype": "code", "value": "normal",
|
||||
"data": [{"value": "low", "text": "低"}, {"value": "normal", "text": "普通"}, {"value": "high", "text": "高"}, {"value": "urgent", "text": "紧急"}]}
|
||||
"data": [{"value": "low", "text": "低"}, {"value": "normal", "text": "普通"}, {"value": "high", "text": "高"}, {"value": "urgent", "text": "紧急"}]},
|
||||
{"name": "attachments", "label": "附件(可选):最多 9 个,总大小 ≤9.5MB,截图/日志等有助于定位问题", "uitype": "file", "multiple": true, "preview": true}
|
||||
]
|
||||
},
|
||||
"binds": [
|
||||
{"wid": "attachments", "event": "changed", "actiontype": "script", "target": "self",
|
||||
"script": "var v=(event&&event.params)?event.params.attachments:null; var fs=v?(Array.isArray(v)?v:[v]):[]; var tot=0; for(var i=0;i<fs.length;i++){tot+=(fs[i].size||0);} var w=bricks.getWidgetById('attachments',bricks.app); if(fs.length>9){new bricks.Message({title:'附件过多',message:'单条消息最多上传 9 个附件,已清空请重新选择'}).open(); if(w&&w.reset){w.reset();} return;} if(tot>9.5*1024*1024){new bricks.Message({title:'附件过大',message:'单次提交附件总大小不能超过 9.5MB,已清空请重新选择'}).open(); if(w&&w.reset){w.reset();} return;}"},
|
||||
{"wid": "self", "event": "submited", "actiontype": "script", "target": "self",
|
||||
"script": "var d=event.params||{}; await bricks.show_resp_message_or_error(d); if(d.success){ var pw=bricks.getWidgetById('new_ticket_pw',bricks.app); if(pw){pw.destroy();} var tbl=bricks.getWidgetById('my_ticket_table',bricks.app.root); if(tbl){await tbl.render({});} if(window.refreshTodo){window.refreshTodo();} }"}
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user