diff --git a/app/pages/tickets/[id].vue b/app/pages/tickets/[id].vue index 71ba548..1a51907 100644 --- a/app/pages/tickets/[id].vue +++ b/app/pages/tickets/[id].vue @@ -142,6 +142,8 @@ var le = { q = ref([]), J = ref(!1), Y = ref(0); + // status 是可自定义的展示文案;所有变更入口只使用服务端操作权限。 + const canOperate = computed(() => V.value?.can_operate === 1); // 读取工单并确认实际存在;后续回复/催单/关闭完成也复用此入口同步服务端状态。 async function X() { try { @@ -191,9 +193,9 @@ var le = { let $ = computed(() => Y.value > 0 ? Z.value.find((e) => e.id === Y.value) : void 0, ); - // 关闭工单不允许上传;逐个上传并检查 save_name,成功部分保留在当前附件草稿。 + // 工单允许操作时逐个上传并检查 save_name,成功部分保留在当前附件草稿。 async function xe(e) { - if (J.value || G.value || V.value?.status === `已关闭`) return; + if (J.value || G.value || !canOperate.value) return; let t = e.target, n = Array.from(t.files ?? []); if (n.length) { @@ -221,7 +223,7 @@ var le = { } // 仅允许引用真实回复 ID;首帖 ID 为 0 不设置为引用目标。 function Ce(e) { - e.id > 0 && V.value?.status !== `已关闭` && (Y.value = e.id); + e.id > 0 && canOperate.value && (Y.value = e.id); } // 引用已删除时明确提示;否则优先用服务端引用快照,再从当前对话寻找原文。 function we(e) { @@ -252,14 +254,9 @@ var le = { [], ); } - // 回复要求内容非空、附件已上传且工单未关闭;成功后清空草稿与引用并重读工单。 + // 回复要求内容非空、附件已上传且服务端允许操作;成功后清空草稿与引用并重读工单。 async function De() { - if (!( - G.value || - !W.value.trim() || - J.value || - V.value?.status === `已关闭` - )) { + if (!(G.value || !W.value.trim() || J.value || !canOperate.value)) { G.value = !0; try { (await z.post(`/ticket/${B}/reply`, { @@ -280,6 +277,7 @@ var le = { } // 催单后重新读取工单,结果与可继续操作状态以服务端为准。 async function Oe() { + if (!canOperate.value) return; K.value = !0; try { (await z.put(`/ticket/${B}/urge`), await X()); @@ -289,8 +287,9 @@ var le = { K.value = !1; } } - // 关闭工单完成后重读详情,由新状态控制回复与上传入口。 + // 关闭工单完成后重读详情,由服务端权限控制回复与上传入口。 async function ke() { + if (!canOperate.value) return; K.value = !0; try { (await z.put(`/ticket/${B}/close`), await X()); @@ -302,7 +301,7 @@ var le = { } return ( onMounted(X), - // 对话按时间展示并使用公共 HTML 清理与附件解析;引用删除有独立文案,关闭工单不再开放回复编辑。 + // 对话按时间展示并使用公共 HTML 清理与附件解析;引用删除有独立文案,只读工单不开放回复编辑。 (n, a) => { let c = TicketNotice, m = NuxtLink, @@ -393,7 +392,7 @@ var le = { 5, ), ]), - unref(V).status === `已关闭` + !unref(canOperate) ? createCommentVNode(``, !0) : (openBlock(), createBlock( @@ -548,8 +547,7 @@ var le = { 8, [`files`], ), - t.id > 0 && - unref(V).status !== `已关闭` + t.id > 0 && unref(canOperate) ? (openBlock(), createBlock( y, @@ -622,7 +620,7 @@ var le = { label: `回复内容`, "min-height": 140, placeholder: `输入回复内容…`, - disabled: unref(V).status === `已关闭`, + disabled: !unref(canOperate), }, { "toolbar-end": withCtx(() => [ @@ -648,7 +646,7 @@ var le = { disabled: unref(G) || unref(J) || - unref(V).status === `已关闭`, + !unref(canOperate), onChange: xe, }, null, @@ -745,7 +743,7 @@ var le = { )) : createCommentVNode(``, !0), createElementVNode(`div`, be, [ - unref(V).status === `已关闭` + !unref(canOperate) ? createCommentVNode(``, !0) : (openBlock(), createBlock( @@ -769,7 +767,7 @@ var le = { label: `提交回复`, loading: unref(G), disabled: - unref(V).status === `已关闭` || + !unref(canOperate) || !unref(W).trim() || unref(J), onClick: De, diff --git a/tests/review-routes.e2e.test.mjs b/tests/review-routes.e2e.test.mjs index 011bde7..40f4dc6 100644 --- a/tests/review-routes.e2e.test.mjs +++ b/tests/review-routes.e2e.test.mjs @@ -186,6 +186,7 @@ test( attachment: [], replies: [], status: "待回复", + can_operate: 1, create_time: 1730000000, username: "audit-user", client_id: 1, @@ -240,7 +241,9 @@ test( return fulfillApi(route, {}); } if (request.method() === "PUT" && match[2] === "close") { - ticket.status = "已关闭"; + // 展示状态允许后台自定义,操作权限由独立字段收回。 + ticket.status = "处理完毕"; + ticket.can_operate = 0; return fulfillApi(route, {}); } return route.fallback(); @@ -286,6 +289,22 @@ test( await page .getByRole("button", { name: "关闭工单", exact: true }) .waitFor({ state: "hidden" }); + assert.equal(await editor.getAttribute("contenteditable"), "false"); + assert.equal(await page.locator("#reply-files").isDisabled(), true); + assert.equal( + await page + .getByRole("button", { name: "提交回复", exact: true }) + .isDisabled(), + true, + ); + assert.equal( + await page.getByRole("button", { name: "引用", exact: true }).count(), + 0, + ); + assert.equal( + await page.getByRole("button", { name: "催单", exact: true }).count(), + 0, + ); const writes = requests.filter((request) => request.method !== "GET"); assert.deepEqual( writes.map(({ method, path }) => ({ method, path })), @@ -310,6 +329,109 @@ test( previousDraftDiscarded: true, writes: writes.map(({ method, path }) => ({ method, path })), previousTicketUnchanged: true, + permissionRevocationDisablesActions: true, }; }), ); + +// 补齐既有工单场景的权限空隙:只读工单在动态详情和旧菜单路由中都不能从展示文案推断可操作。 +for (const [routePath, status] of [ + ["/tickets/2", "处理中"], + ["/tickets/ticket?id=2", "服务已结束"], +]) { + test( + `ticket can_operate denies writes at ${routePath} with custom display status`, + { timeout: 25000 }, + (t) => + scenario(t, { loginMode: "password-only" }, async (page) => { + const writes = []; + const ticket = { + id: 2, + ticket_num: "AUDIT-2", + title: "只读工单", + content: "

历史工单仍可阅读

", + status, + can_operate: 0, + create_time: 1730000000, + username: "audit-user", + client_id: 1, + color: "#777", + attachment: [], + replies: [ + { + id: 10, + content: "

历史客服回复

", + attachment: [], + create_time: 1730000001, + type: "Admin", + admin_name: "客服", + quote_reply_id: 0, + quote_info: null, + }, + ], + }; + await page.route( + (url) => + url.origin === backendOrigin && + (url.pathname.startsWith("/console/v1/ticket/") || + url.pathname === "/console/v1/upload"), + async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname.replace( + "/console/v1", + "", + ); + if (path === "/ticket/config") + return fulfillApi(route, { ticket_notice_open: 0 }); + if (request.method() === "GET" && path === "/ticket/2") + return fulfillApi(route, { ticket }); + if (["POST", "PUT"].includes(request.method())) { + writes.push({ method: request.method(), path }); + return fulfillApi(route, { save_name: "unused-attachment.txt" }); + } + return route.fallback(); + }, + ); + await login(page, routePath); + await page + .getByRole("heading", { name: ticket.title, exact: true }) + .waitFor(); + assert.equal( + await page.getByText("历史客服回复", { exact: true }).isVisible(), + true, + ); + const editor = page.getByRole("textbox", { + name: "回复内容", + exact: true, + }); + assert.equal(await editor.getAttribute("contenteditable"), "false"); + assert.equal(await page.locator("#reply-files").isDisabled(), true); + assert.equal( + await page + .getByRole("button", { name: "提交回复", exact: true }) + .isDisabled(), + true, + ); + for (const name of ["引用", "催单", "关闭工单"]) + assert.equal( + await page.getByRole("button", { name, exact: true }).count(), + 0, + ); + // 文件输入收到浏览器 change 事件时,处理函数也必须遵守权限;仅修改 HTTP 外部边界。 + await page.locator("#reply-files").setInputFiles({ + name: "read-only-ticket.txt", + mimeType: "text/plain", + buffer: Buffer.from("local permission regression"), + }); + assert.deepEqual(writes, []); + return { + routePath, + displayStatus: status, + serverCanOperate: ticket.can_operate, + historicalRepliesReadable: true, + editingAndWritesBlocked: true, + writes, + }; + }), + ); +}