修复工单操作权限判断并补充路由回归

This commit is contained in:
cieanyau committed 2026-11-07 01:02:07 +08:00
1 parent 42dfead9cd
commit 1fe68cc8dd
2 files changed
+140 -20

No files matched your search

+17 -19
View File
@@ -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,
+123 -1
View File
@@ -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: "<p>历史工单仍可阅读</p>",
status,
can_operate: 0,
create_time: 1730000000,
username: "audit-user",
client_id: 1,
color: "#777",
attachment: [],
replies: [
{
id: 10,
content: "<p>历史客服回复</p>",
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,
};
}),
);
}