diff --git a/scripts/load_path.py b/scripts/load_path.py index 29fee8b..899b719 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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", diff --git a/ticket/core.py b/ticket/core.py index 38ceba1..1785cad 100644 --- a/ticket/core.py +++ b/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, diff --git a/ticket/init.py b/ticket/init.py index c084979..d29988c 100644 --- a/ticket/init.py +++ b/ticket/init.py @@ -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: diff --git a/wwwroot/api/ticket_attachment.dspy b/wwwroot/api/ticket_attachment.dspy new file mode 100644 index 0000000..6644628 --- /dev/null +++ b/wwwroot/api/ticket_attachment.dspy @@ -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) diff --git a/wwwroot/api/ticket_create.dspy b/wwwroot/api/ticket_create.dspy index dff50e6..f060706 100644 --- a/wwwroot/api/ticket_create.dspy +++ b/wwwroot/api/ticket_create.dspy @@ -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, diff --git a/wwwroot/api/ticket_detail_popup.dspy b/wwwroot/api/ticket_detail_popup.dspy index 1132f30..be6855a 100644 --- a/wwwroot/api/ticket_detail_popup.dspy +++ b/wwwroot/api/ticket_detail_popup.dspy @@ -1,8 +1,11 @@ # ticket_detail_popup.dspy - 工单详情弹窗:正文与操作同屏(复核类弹窗规范) # 页面入口与平台待办共用。入参: 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: diff --git a/wwwroot/api/ticket_followup.dspy b/wwwroot/api/ticket_followup.dspy index 49518f4..e6d59cc 100644 --- a/wwwroot/api/ticket_followup.dspy +++ b/wwwroot/api/ticket_followup.dspy @@ -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) diff --git a/wwwroot/api/ticket_staff_reply.dspy b/wwwroot/api/ticket_staff_reply.dspy index 8834f35..1ac5f45 100644 --- a/wwwroot/api/ticket_staff_reply.dspy +++ b/wwwroot/api/ticket_staff_reply.dspy @@ -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) diff --git a/wwwroot/my/new_ticket_form.ui b/wwwroot/my/new_ticket_form.ui index d440466..650a076 100644 --- a/wwwroot/my/new_ticket_form.ui +++ b/wwwroot/my/new_ticket_form.ui @@ -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;i9){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();} }"} ]