diff --git a/app/assets/styles/_id_.C-0--Lx-.css b/app/assets/styles/_id_.C-0--Lx-.css index f95b2eb..4a46a05 100644 --- a/app/assets/styles/_id_.C-0--Lx-.css +++ b/app/assets/styles/_id_.C-0--Lx-.css @@ -312,6 +312,7 @@ display: none; } .reply-upload[data-v-3743a20f] { + align-self: flex-start; color: var(--p-text-muted-color); cursor: pointer; border-radius: 3px; diff --git a/app/assets/styles/finance.BkxKMno0.css b/app/assets/styles/finance.BkxKMno0.css index e2c2431..624d963 100644 --- a/app/assets/styles/finance.BkxKMno0.css +++ b/app/assets/styles/finance.BkxKMno0.css @@ -199,6 +199,12 @@ flex-basis: 100%; margin: 0; } +.reminder-status[data-v-18fe1f08] { + flex-basis: 100%; + margin: 0; + color: var(--p-text-muted-color); + font-size: 0.875rem; +} .panel.wallet-tabs[data-v-18fe1f08] { background: 0 0; border: 0; diff --git a/app/pages/finance/index.vue b/app/pages/finance/index.vue index 915cdd1..f0e5403 100644 --- a/app/pages/finance/index.vue +++ b/app/pages/finance/index.vue @@ -137,7 +137,13 @@ var je = { let _ = useApi(), x = useRoute(), S = useAuth(), - { rechargeClosed: Qe, load: C } = useSiteConfig_t(), + { + config: siteConfig, + error: siteConfigError, + on: configOn, + rechargeClosed: Qe, + load: C, + } = useSiteConfig_t(), { enabled: $e, load: w } = useWithdrawAvailability_t(), T = ref(20), E = ref(!1), @@ -149,6 +155,19 @@ var je = { et = ref(!1), M = ref(null), N = ref(``); + // 只有站点明确开启时才允许修改;加载、缺项与失败均保留可见说明。 + const reminderEnabled = computed(() => + configOn(siteConfig.value?.balance_notice_show), + ); + const reminderStatus = computed(() => { + if (!siteConfig.value) + return siteConfigError.value + ? `站点配置加载失败,暂时无法修改余额提醒。请刷新后重试。` + : `正在加载余额提醒配置…`; + if (siteConfig.value.balance_notice_show == null) + return `站点尚未提供余额提醒配置,暂时无法修改。`; + return reminderEnabled.value ? `` : `站点已关闭余额提醒。`; + }); // 账户总览变化时同步余额提醒设置,仅回填本地草稿。 function tt() { let e = S.overview; @@ -161,6 +180,7 @@ var je = { }); // 开启余额提醒必须填写正的有限阈值;关闭时发送零阈值,成功后同步账户总览。 async function nt() { + if (A.value || !reminderEnabled.value) return; ((j.value = ``), (et.value = !1)); let e = Number(k.value ?? 0); if (O.value && (!Number.isFinite(e) || e <= 0)) { @@ -441,34 +461,34 @@ var je = { (S.refreshOverview(), Q(), X()); } let Et = ref([]), - Dt = ref(0), Ot = ref(1), kt = ref(!1), $ = ref(``), freezeRequestVersion = 0; - // 冻结记录与其他流水遵守同一代次规则,旧请求不能改写结果、错误或加载状态。 + // 冻结接口返回全量记录,由表格本地分页;旧请求不能改写新结果或加载状态。 async function At() { if (disposed) return; let requestVersion = ++freezeRequestVersion; ((kt.value = !0), ($.value = ``)); try { - let e = await _.get(`/account/credit/freeze`, { - page: Ot.value, - limit: T.value, - }); + let e = await _.get(`/account/credit/freeze`); if (requestVersion !== freezeRequestVersion) return; - ((Et.value = e.list ?? []), (Dt.value = e.count ?? 0)); + Et.value = e.list ?? []; + Ot.value = Math.min( + Ot.value, + Math.max(1, Math.ceil(Et.value.length / T.value)), + ); } catch (e) { if (requestVersion !== freezeRequestVersion) return; (($.value = e instanceof Error ? e.message : `加载冻结记录失败`), (Et.value = []), - (Dt.value = 0)); + (Ot.value = 1)); } finally { requestVersion === freezeRequestVersion && (kt.value = !1); } } function jt(e) { - ((Ot.value = e.page + 1), (T.value = e.rows), At()); + ((Ot.value = e.page + 1), (T.value = e.rows)); } return ( // 与共用列表加载器一致:销毁后拒绝新读取,并作废各列表全部在途响应。 @@ -699,7 +719,7 @@ var je = { "onUpdate:modelValue": (n[2] ||= (e) => isRef(O) ? (O.value = e) : null), inputId: `credit-reminder-enabled`, - disabled: unref(A), + disabled: unref(A) || !unref(reminderEnabled), }, null, 8, @@ -725,7 +745,8 @@ var je = { minFractionDigits: 2, maxFractionDigits: 2, suffix: ` 元`, - disabled: !unref(O) || unref(A), + disabled: + !unref(O) || unref(A) || !unref(reminderEnabled), }, null, 8, @@ -737,13 +758,22 @@ var je = { label: `保存`, icon: `pi pi-check`, loading: unref(A), + disabled: unref(A) || !unref(reminderEnabled), onClick: nt, }, null, 8, - [`loading`], + [`loading`, `disabled`], ), ]), + unref(reminderStatus) + ? createElementVNode( + `p`, + { class: `reminder-status`, role: `status` }, + toDisplayString(unref(reminderStatus)), + 1, + ) + : createCommentVNode(``, !0), unref(j) ? (openBlock(), createBlock( @@ -1791,10 +1821,8 @@ var je = { { value: unref(Et), loading: unref(kt), - lazy: ``, paginator: ``, rows: unref(T), - totalRecords: unref(Dt), first: (unref(Ot) - 1) * unref(T), rowsPerPageOptions: [20, 50, 100], dataKey: `id`, diff --git a/app/pages/tickets/[id].vue b/app/pages/tickets/[id].vue index 67d27a5..1d8abc4 100644 --- a/app/pages/tickets/[id].vue +++ b/app/pages/tickets/[id].vue @@ -4,7 +4,6 @@ */ import { definePageMeta } from "#imports"; import "~/assets/styles/TicketAttachments.DtbJGLkE.css"; -import "~/assets/styles/RichTextEditor.NkmgPT5m.css"; import "~/assets/styles/_id_.C-0--Lx-.css"; definePageMeta({ key: (e) => e.fullPath, @@ -19,7 +18,6 @@ import { ref } from "vue"; import { withCtx } from "vue"; import { createCommentVNode } from "vue"; import { computed } from "vue"; -import { isRef } from "vue"; import { createElementBlock } from "vue"; import { Fragment } from "vue"; import { createElementVNode } from "vue"; @@ -38,7 +36,7 @@ import message from "openvue/message"; import { r as dict_r } from "~/lib/dict.js"; import skeleton from "openvue/skeleton"; import { n as sanitize_n } from "~/lib/sanitize.js"; -import RichTextEditor from "~/components/RichTextEditor.vue"; +import textarea from "openvue/textarea"; import { n as ticketAttachments_n } from "~/lib/ticketAttachments.js"; import TicketAttachments from "~/components/TicketAttachments.vue"; import TicketNotice from "~/components/TicketNotice.vue"; @@ -84,12 +82,10 @@ var le = { k = { class: `time`, }, - A = [`innerHTML`], j = { key: 0, class: `message-quote`, }, - M = [`innerHTML`], N = { class: `reply-box`, }, @@ -98,7 +94,6 @@ var le = { class: `reply-quote`, role: `status`, }, - F = [`innerHTML`], I = { class: `reply-attachments`, }, @@ -173,6 +168,7 @@ var le = { { id: 0, content: e.content, + content_format: e.content_format, attachment: e.attachment || [], create_time: e.create_time, type: `Client`, @@ -235,13 +231,26 @@ var le = { function Ce(e) { e.id > 0 && canOperate.value && (Y.value = e.id); } - // 引用已删除时明确提示;否则优先用服务端引用快照,再从当前对话寻找原文。 + // 正文和引用共用格式边界:纯文本不经过 HTML 解码,历史富文本继续净化。 + function contentProps(item) { + return item?.content_format === `text` + ? { + textContent: String(item.content ?? ``), + style: { whiteSpace: `pre-wrap`, overflowWrap: `anywhere` }, + } + : { innerHTML: sanitize_n(item?.content) }; + } + // 引用快照保留服务端格式;删除提示或缺失提示明确使用纯文本。 function we(e) { - return Number(e.quote_info?.is_deleted) === 1 - ? `该回复已删除` - : e.quote_info?.content || - Z.value.find((t) => t.id === e.quote_reply_id)?.content || - `原回复 #${e.quote_reply_id}(内容暂不可用)`; + if (Number(e.quote_info?.is_deleted) === 1) + return { content: `该回复已删除`, content_format: `text` }; + if (typeof e.quote_info?.content === `string`) return e.quote_info; + return ( + Z.value.find((t) => t.id === e.quote_reply_id) || { + content: `原回复 #${e.quote_reply_id}(内容暂不可用)`, + content_format: `text`, + } + ); } // 引用发送者优先来自引用快照,缺失时回查当前对话。 function Te(e) { @@ -273,7 +282,7 @@ var le = { G.value = !0; try { await z.post(`/ticket/${B}/reply`, { - content: W.value.trim(), + content: W.value, attachment: q.value.map((e) => e.save_name), quote_reply_id: Y.value, }); @@ -320,7 +329,7 @@ var le = { } return ( onMounted(X), - // 对话按时间展示并使用公共 HTML 清理与附件解析;引用删除有独立文案,只读工单不开放回复编辑。 + // 对话遵循服务端内容格式;客户回复使用纯文本,只读工单不开放编辑。 (n, a) => { let c = TicketNotice, m = NuxtLink, @@ -328,7 +337,7 @@ var le = { v = message, y = button, S = TicketAttachments, - C = RichTextEditor; + C = textarea; return ( openBlock(), createElementBlock(`div`, le, [ @@ -513,13 +522,10 @@ var le = { `div`, { class: `message-body`, - innerHTML: (`sanitizeHtml` in n - ? n.sanitizeHtml - : unref(sanitize_n))(t.content), + ...contentProps(t), }, null, - 8, - A, + 16, ), t.quote_reply_id ? (openBlock(), @@ -535,16 +541,10 @@ var le = { createElementVNode( `div`, { - innerHTML: - (`sanitizeHtml` in n - ? n.sanitizeHtml - : unref(sanitize_n))( - we(t), - ), + ...contentProps(we(t)), }, null, - 8, - M, + 16, ), createVNode( S, @@ -608,15 +608,10 @@ var le = { createElementVNode( `div`, { - innerHTML: (`sanitizeHtml` in n - ? n.sanitizeHtml - : unref(sanitize_n))( - unref($).content, - ), + ...contentProps(unref($)), }, null, - 8, - F, + 16, ), ]), createVNode(y, { @@ -635,50 +630,43 @@ var le = { { modelValue: unref(W), "onUpdate:modelValue": (a[1] ||= (e) => - isRef(W) ? (W.value = e) : null), - label: `回复内容`, - "min-height": 140, + (W.value = e)), + "aria-label": `回复内容`, + rows: 6, + autoResize: true, + fluid: true, placeholder: `输入回复内容…`, - disabled: !unref(canOperate), - }, - { - "toolbar-end": withCtx(() => [ - createElementVNode(`label`, L, [ - (a[3] ||= createElementVNode( - `i`, - { - class: `pi pi-paperclip`, - }, - null, - -1, - )), - (a[4] ||= createTextVNode( - `添加附件`, - -1, - )), - createElementVNode( - `input`, - { - id: `reply-files`, - type: `file`, - multiple: ``, - disabled: - unref(G) || - unref(J) || - !unref(canOperate), - onChange: xe, - }, - null, - 40, - _e, - ), - ]), - ]), - _: 1, + disabled: !unref(canOperate) || unref(G), }, + null, 8, [`modelValue`, `disabled`], ), + createElementVNode(`label`, L, [ + (a[3] ||= createElementVNode( + `i`, + { class: `pi pi-paperclip` }, + null, + -1, + )), + (a[4] ||= createTextVNode(`添加附件`, -1)), + createElementVNode( + `input`, + { + id: `reply-files`, + type: `file`, + multiple: ``, + disabled: + unref(G) || + unref(J) || + !unref(canOperate), + onChange: xe, + }, + null, + 40, + _e, + ), + ]), createElementVNode(`div`, I, [ unref(J) ? (openBlock(), diff --git a/app/pages/tickets/create.vue b/app/pages/tickets/create.vue index 9f73a82..3b4dd1e 100644 --- a/app/pages/tickets/create.vue +++ b/app/pages/tickets/create.vue @@ -1,8 +1,7 @@ \n 保留缩进 "; + await page + .getByRole("textbox", { name: "回复内容", exact: true }) + .fill(draft); + await page.getByRole("button", { name: "提交回复", exact: true }).click(); + await eventually( + () => writes, + (rows) => rows.length === 1, + "回复必须提交一次", + ); + assert.equal(writes[0].content, draft); + assert.equal(writes[0].quote_reply_id, 11); + await messages.nth(4).waitFor(); + assert.equal( + await messages.nth(4).locator(".message-body").textContent(), + draft, + ); + return { + textAndSnapshotsKeptLiteral: true, + historicalHtmlRendered: true, + replyKeptWhitespace: true, + quotedReply: 11, + }; + }), +); + +test( + "full freeze lists paginate locally without duplicate rows or extra reads", + { timeout: 30000 }, + (t) => + scenario(t, { loginMode: "password-only" }, async (page) => { + await financeLists(page); + let reads = 0; + const list = Array.from({ length: 21 }, (_, index) => ({ + id: index + 1, + amount: "10.00", + client_notes: `冻结记录-${index + 1}`, + create_time: 1730000000, + })); + await page.route( + (url) => + url.origin === backendOrigin && + url.pathname === "/console/v1/account/credit/freeze", + (route) => { + reads++; + return reply(route, { list, count: list.length }); + }, + ); + await login(page, "/finance?tab=freeze"); + const panel = page.getByRole("tabpanel").filter({ visible: true }); + await panel.getByText("冻结记录-1", { exact: true }).waitFor(); + assert.equal(await panel.locator("tbody tr").count(), 20); + await panel.locator(".p-paginator-next").click(); + await panel.getByText("冻结记录-21", { exact: true }).waitFor(); + assert.equal(await panel.locator("tbody tr").count(), 1); + assert.equal( + await panel.getByText("冻结记录-1", { exact: true }).count(), + 0, + ); + assert.equal(reads, 1); + return { firstPageRows: 20, secondPageRows: 1, totalReads: reads }; + }), +); + +for (const mode of ["enabled", "disabled", "missing", "failure"]) { + test( + `balance reminders follow the ${mode} site capability`, + { timeout: 30000 }, + (t) => + scenario(t, { loginMode: "password-only" }, async (page) => { + const writes = []; + await financeLists(page); + // 配置失败场景先经真实表单建立会话,再整页刷新触发新的公共配置读取。 + if (mode === "failure") await login(page, "/finance"); + await page.route( + (url) => + url.origin === backendOrigin && + url.pathname === "/console/v1/common", + async (route) => { + if (mode === "failure") + return route.fulfill({ + json: { status: 400, msg: "本地配置暂不可用", data: {} }, + headers: { "Access-Control-Allow-Origin": appOrigin }, + }); + const response = await route.fetch(); + const payload = await response.json(); + if (mode === "missing") delete payload.data.balance_notice_show; + else payload.data.balance_notice_show = mode === "enabled" ? 1 : 0; + return route.fulfill({ response, json: payload }); + }, + ); + await page.route( + (url) => + url.origin === backendOrigin && + url.pathname === "/console/v1/account/credit/remind", + (route) => { + writes.push(route.request().postDataJSON()); + return reply(route, {}); + }, + ); + if (mode === "failure") await page.goto(`${appOrigin}/finance`); + else await login(page, "/finance"); + const panel = page.locator(".reminder-panel"); + const save = panel.getByRole("button", { name: "保存", exact: true }); + await save.waitFor(); + if (mode === "enabled") { + await panel.locator("#credit-reminder-enabled").check(); + await panel.locator("#credit-reminder-amount").fill("25.00"); + await save.click(); + await eventually( + () => writes, + (rows) => rows.length === 1, + "开启能力后可保存提醒", + ); + assert.deepEqual(writes[0], { + credit_remind: 1, + credit_remind_amount: 25, + }); + } else { + assert.equal(await save.isDisabled(), true); + assert.equal( + await panel.locator("#credit-reminder-enabled").isDisabled(), + true, + ); + assert.equal( + await panel.locator("#credit-reminder-amount").isDisabled(), + true, + ); + assert.equal(await panel.getByRole("status").isVisible(), true); + assert.equal(writes.length, 0); + } + return { + capability: mode, + saved: mode === "enabled", + writes: writes.length, + }; + }), + ); +} + +test( + "balance reminders stay disabled while a failed startup configuration is being reloaded", + { timeout: 30000 }, + (t) => + scenario(t, { loginMode: "password-only" }, async (page) => { + await financeLists(page); + await login(page, "/finance"); + let reads = 0; + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route( + (url) => + url.origin === backendOrigin && url.pathname === "/console/v1/common", + async (route) => { + if (++reads === 1) + return route.fulfill({ + json: { status: 400, msg: "本地首次配置读取失败", data: {} }, + headers: { "Access-Control-Allow-Origin": appOrigin }, + }); + await held; + const response = await route.fetch(); + const payload = await response.json(); + payload.data.balance_notice_show = 1; + return route.fulfill({ response, json: payload }); + }, + ); + try { + await page.goto(`${appOrigin}/finance`); + const panel = page.locator(".reminder-panel"); + const save = panel.getByRole("button", { name: "保存", exact: true }); + await save.waitFor(); + assert.equal(await save.isDisabled(), true); + assert.equal( + await panel.locator("#credit-reminder-enabled").isDisabled(), + true, + ); + assert.equal(await panel.getByRole("status").isVisible(), true); + release(); + await eventually( + () => save.isEnabled(), + Boolean, + "配置确认开启后才允许保存", + ); + return { pendingCapabilityDisabled: true, enabledAfterReload: true }; + } finally { + release(); + } + }), +); diff --git a/tests/review-routes.e2e.test.mjs b/tests/review-routes.e2e.test.mjs index 47ca7c9..09e1d80 100644 --- a/tests/review-routes.e2e.test.mjs +++ b/tests/review-routes.e2e.test.mjs @@ -272,7 +272,7 @@ test( await page .getByRole("heading", { name: "审计工单2", exact: true }) .waitFor(); - assert.equal((await editor.innerText()).trim(), ""); + assert.equal(await editor.inputValue(), ""); await editor.fill("第二张工单的回复"); await page.getByRole("button", { name: "提交回复", exact: true }).click(); await eventually( @@ -289,7 +289,7 @@ test( await page .getByRole("button", { name: "关闭工单", exact: true }) .waitFor({ state: "hidden" }); - assert.equal(await editor.getAttribute("contenteditable"), "false"); + assert.equal(await editor.isEditable(), false); assert.equal(await page.locator("#reply-files").isDisabled(), true); assert.equal( await page @@ -404,7 +404,7 @@ for (const [routePath, status] of [ name: "回复内容", exact: true, }); - assert.equal(await editor.getAttribute("contenteditable"), "false"); + assert.equal(await editor.isEditable(), false); assert.equal(await page.locator("#reply-files").isDisabled(), true); assert.equal( await page