修复工单内容格式与财务页面接口契约

This commit is contained in:
cieanyau committed 2026-11-07 08:12:28 +08:00
1 parent 90989f8f8a
commit d330ad01cc
8 files changed
+551 -149

No files matched your search

+1
View File
@@ -312,6 +312,7 @@
display: none; display: none;
} }
.reply-upload[data-v-3743a20f] { .reply-upload[data-v-3743a20f] {
align-self: flex-start;
color: var(--p-text-muted-color); color: var(--p-text-muted-color);
cursor: pointer; cursor: pointer;
border-radius: 3px; border-radius: 3px;
+6
View File
@@ -199,6 +199,12 @@
flex-basis: 100%; flex-basis: 100%;
margin: 0; 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] { .panel.wallet-tabs[data-v-18fe1f08] {
background: 0 0; background: 0 0;
border: 0; border: 0;
+43 -15
View File
@@ -137,7 +137,13 @@ var je = {
let _ = useApi(), let _ = useApi(),
x = useRoute(), x = useRoute(),
S = useAuth(), 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(), { enabled: $e, load: w } = useWithdrawAvailability_t(),
T = ref(20), T = ref(20),
E = ref(!1), E = ref(!1),
@@ -149,6 +155,19 @@ var je = {
et = ref(!1), et = ref(!1),
M = ref(null), M = ref(null),
N = ref(``); 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() { function tt() {
let e = S.overview; let e = S.overview;
@@ -161,6 +180,7 @@ var je = {
}); });
// 开启余额提醒必须填写正的有限阈值;关闭时发送零阈值,成功后同步账户总览。 // 开启余额提醒必须填写正的有限阈值;关闭时发送零阈值,成功后同步账户总览。
async function nt() { async function nt() {
if (A.value || !reminderEnabled.value) return;
((j.value = ``), (et.value = !1)); ((j.value = ``), (et.value = !1));
let e = Number(k.value ?? 0); let e = Number(k.value ?? 0);
if (O.value && (!Number.isFinite(e) || e <= 0)) { if (O.value && (!Number.isFinite(e) || e <= 0)) {
@@ -441,34 +461,34 @@ var je = {
(S.refreshOverview(), Q(), X()); (S.refreshOverview(), Q(), X());
} }
let Et = ref([]), let Et = ref([]),
Dt = ref(0),
Ot = ref(1), Ot = ref(1),
kt = ref(!1), kt = ref(!1),
$ = ref(``), $ = ref(``),
freezeRequestVersion = 0; freezeRequestVersion = 0;
// 冻结记录与其他流水遵守同一代次规则,旧请求不能改写结果、错误或加载状态。 // 冻结接口返回全量记录,由表格本地分页;旧请求不能改写新结果或加载状态。
async function At() { async function At() {
if (disposed) return; if (disposed) return;
let requestVersion = ++freezeRequestVersion; let requestVersion = ++freezeRequestVersion;
((kt.value = !0), ($.value = ``)); ((kt.value = !0), ($.value = ``));
try { try {
let e = await _.get(`/account/credit/freeze`, { let e = await _.get(`/account/credit/freeze`);
page: Ot.value,
limit: T.value,
});
if (requestVersion !== freezeRequestVersion) return; 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) { } catch (e) {
if (requestVersion !== freezeRequestVersion) return; if (requestVersion !== freezeRequestVersion) return;
(($.value = e instanceof Error ? e.message : `加载冻结记录失败`), (($.value = e instanceof Error ? e.message : `加载冻结记录失败`),
(Et.value = []), (Et.value = []),
(Dt.value = 0)); (Ot.value = 1));
} finally { } finally {
requestVersion === freezeRequestVersion && (kt.value = !1); requestVersion === freezeRequestVersion && (kt.value = !1);
} }
} }
function jt(e) { function jt(e) {
((Ot.value = e.page + 1), (T.value = e.rows), At()); ((Ot.value = e.page + 1), (T.value = e.rows));
} }
return ( return (
// 与共用列表加载器一致:销毁后拒绝新读取,并作废各列表全部在途响应。 // 与共用列表加载器一致:销毁后拒绝新读取,并作废各列表全部在途响应。
@@ -699,7 +719,7 @@ var je = {
"onUpdate:modelValue": (n[2] ||= (e) => "onUpdate:modelValue": (n[2] ||= (e) =>
isRef(O) ? (O.value = e) : null), isRef(O) ? (O.value = e) : null),
inputId: `credit-reminder-enabled`, inputId: `credit-reminder-enabled`,
disabled: unref(A), disabled: unref(A) || !unref(reminderEnabled),
}, },
null, null,
8, 8,
@@ -725,7 +745,8 @@ var je = {
minFractionDigits: 2, minFractionDigits: 2,
maxFractionDigits: 2, maxFractionDigits: 2,
suffix: ` 元`, suffix: ` 元`,
disabled: !unref(O) || unref(A), disabled:
!unref(O) || unref(A) || !unref(reminderEnabled),
}, },
null, null,
8, 8,
@@ -737,13 +758,22 @@ var je = {
label: `保存`, label: `保存`,
icon: `pi pi-check`, icon: `pi pi-check`,
loading: unref(A), loading: unref(A),
disabled: unref(A) || !unref(reminderEnabled),
onClick: nt, onClick: nt,
}, },
null, null,
8, 8,
[`loading`], [`loading`, `disabled`],
), ),
]), ]),
unref(reminderStatus)
? createElementVNode(
`p`,
{ class: `reminder-status`, role: `status` },
toDisplayString(unref(reminderStatus)),
1,
)
: createCommentVNode(``, !0),
unref(j) unref(j)
? (openBlock(), ? (openBlock(),
createBlock( createBlock(
@@ -1791,10 +1821,8 @@ var je = {
{ {
value: unref(Et), value: unref(Et),
loading: unref(kt), loading: unref(kt),
lazy: ``,
paginator: ``, paginator: ``,
rows: unref(T), rows: unref(T),
totalRecords: unref(Dt),
first: (unref(Ot) - 1) * unref(T), first: (unref(Ot) - 1) * unref(T),
rowsPerPageOptions: [20, 50, 100], rowsPerPageOptions: [20, 50, 100],
dataKey: `id`, dataKey: `id`,
+62 -74
View File
@@ -4,7 +4,6 @@
*/ */
import { definePageMeta } from "#imports"; import { definePageMeta } from "#imports";
import "~/assets/styles/TicketAttachments.DtbJGLkE.css"; import "~/assets/styles/TicketAttachments.DtbJGLkE.css";
import "~/assets/styles/RichTextEditor.NkmgPT5m.css";
import "~/assets/styles/_id_.C-0--Lx-.css"; import "~/assets/styles/_id_.C-0--Lx-.css";
definePageMeta({ definePageMeta({
key: (e) => e.fullPath, key: (e) => e.fullPath,
@@ -19,7 +18,6 @@ import { ref } from "vue";
import { withCtx } from "vue"; import { withCtx } from "vue";
import { createCommentVNode } from "vue"; import { createCommentVNode } from "vue";
import { computed } from "vue"; import { computed } from "vue";
import { isRef } from "vue";
import { createElementBlock } from "vue"; import { createElementBlock } from "vue";
import { Fragment } from "vue"; import { Fragment } from "vue";
import { createElementVNode } 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 { r as dict_r } from "~/lib/dict.js";
import skeleton from "openvue/skeleton"; import skeleton from "openvue/skeleton";
import { n as sanitize_n } from "~/lib/sanitize.js"; 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 { n as ticketAttachments_n } from "~/lib/ticketAttachments.js";
import TicketAttachments from "~/components/TicketAttachments.vue"; import TicketAttachments from "~/components/TicketAttachments.vue";
import TicketNotice from "~/components/TicketNotice.vue"; import TicketNotice from "~/components/TicketNotice.vue";
@@ -84,12 +82,10 @@ var le = {
k = { k = {
class: `time`, class: `time`,
}, },
A = [`innerHTML`],
j = { j = {
key: 0, key: 0,
class: `message-quote`, class: `message-quote`,
}, },
M = [`innerHTML`],
N = { N = {
class: `reply-box`, class: `reply-box`,
}, },
@@ -98,7 +94,6 @@ var le = {
class: `reply-quote`, class: `reply-quote`,
role: `status`, role: `status`,
}, },
F = [`innerHTML`],
I = { I = {
class: `reply-attachments`, class: `reply-attachments`,
}, },
@@ -173,6 +168,7 @@ var le = {
{ {
id: 0, id: 0,
content: e.content, content: e.content,
content_format: e.content_format,
attachment: e.attachment || [], attachment: e.attachment || [],
create_time: e.create_time, create_time: e.create_time,
type: `Client`, type: `Client`,
@@ -235,13 +231,26 @@ var le = {
function Ce(e) { function Ce(e) {
e.id > 0 && canOperate.value && (Y.value = e.id); 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) { function we(e) {
return Number(e.quote_info?.is_deleted) === 1 if (Number(e.quote_info?.is_deleted) === 1)
? `该回复已删除` return { content: `该回复已删除`, content_format: `text` };
: e.quote_info?.content || if (typeof e.quote_info?.content === `string`) return e.quote_info;
Z.value.find((t) => t.id === e.quote_reply_id)?.content || return (
`原回复 #${e.quote_reply_id}(内容暂不可用)`; Z.value.find((t) => t.id === e.quote_reply_id) || {
content: `原回复 #${e.quote_reply_id}(内容暂不可用)`,
content_format: `text`,
}
);
} }
// 引用发送者优先来自引用快照,缺失时回查当前对话。 // 引用发送者优先来自引用快照,缺失时回查当前对话。
function Te(e) { function Te(e) {
@@ -273,7 +282,7 @@ var le = {
G.value = !0; G.value = !0;
try { try {
await z.post(`/ticket/${B}/reply`, { await z.post(`/ticket/${B}/reply`, {
content: W.value.trim(), content: W.value,
attachment: q.value.map((e) => e.save_name), attachment: q.value.map((e) => e.save_name),
quote_reply_id: Y.value, quote_reply_id: Y.value,
}); });
@@ -320,7 +329,7 @@ var le = {
} }
return ( return (
onMounted(X), onMounted(X),
// 对话按时间展示并使用公共 HTML 清理与附件解析;引用删除有独立文案,只读工单不开放回复编辑。 // 对话遵循服务端内容格式;客户回复使用纯文本,只读工单不开放编辑。
(n, a) => { (n, a) => {
let c = TicketNotice, let c = TicketNotice,
m = NuxtLink, m = NuxtLink,
@@ -328,7 +337,7 @@ var le = {
v = message, v = message,
y = button, y = button,
S = TicketAttachments, S = TicketAttachments,
C = RichTextEditor; C = textarea;
return ( return (
openBlock(), openBlock(),
createElementBlock(`div`, le, [ createElementBlock(`div`, le, [
@@ -513,13 +522,10 @@ var le = {
`div`, `div`,
{ {
class: `message-body`, class: `message-body`,
innerHTML: (`sanitizeHtml` in n ...contentProps(t),
? n.sanitizeHtml
: unref(sanitize_n))(t.content),
}, },
null, null,
8, 16,
A,
), ),
t.quote_reply_id t.quote_reply_id
? (openBlock(), ? (openBlock(),
@@ -535,16 +541,10 @@ var le = {
createElementVNode( createElementVNode(
`div`, `div`,
{ {
innerHTML: ...contentProps(we(t)),
(`sanitizeHtml` in n
? n.sanitizeHtml
: unref(sanitize_n))(
we(t),
),
}, },
null, null,
8, 16,
M,
), ),
createVNode( createVNode(
S, S,
@@ -608,15 +608,10 @@ var le = {
createElementVNode( createElementVNode(
`div`, `div`,
{ {
innerHTML: (`sanitizeHtml` in n ...contentProps(unref($)),
? n.sanitizeHtml
: unref(sanitize_n))(
unref($).content,
),
}, },
null, null,
8, 16,
F,
), ),
]), ]),
createVNode(y, { createVNode(y, {
@@ -635,50 +630,43 @@ var le = {
{ {
modelValue: unref(W), modelValue: unref(W),
"onUpdate:modelValue": (a[1] ||= (e) => "onUpdate:modelValue": (a[1] ||= (e) =>
isRef(W) ? (W.value = e) : null), (W.value = e)),
label: `回复内容`, "aria-label": `回复内容`,
"min-height": 140, rows: 6,
autoResize: true,
fluid: true,
placeholder: `输入回复内容…`, placeholder: `输入回复内容…`,
disabled: !unref(canOperate), disabled: !unref(canOperate) || unref(G),
},
{
"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,
}, },
null,
8, 8,
[`modelValue`, `disabled`], [`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, [ createElementVNode(`div`, I, [
unref(J) unref(J)
? (openBlock(), ? (openBlock(),
+13 -56
View File
@@ -1,8 +1,7 @@
<script> <script>
/** /**
* 创建工单:加载类型/部门及可选实例,上传附件后提交标题、富文本内容和关联实例;选项未就绪或附件仍上传时不提交。 * 创建工单:加载实际部门及可选实例,上传附件后提交标题、纯文本内容和关联实例;选项未就绪或附件仍上传时不提交。
*/ */
import "~/assets/styles/RichTextEditor.NkmgPT5m.css";
import "~/assets/styles/create.gX6gpD00.css"; import "~/assets/styles/create.gX6gpD00.css";
import { toDisplayString } from "vue"; import { toDisplayString } from "vue";
import { renderList } from "vue"; import { renderList } from "vue";
@@ -32,8 +31,7 @@ import message from "openvue/message";
import radiobutton from "openvue/radiobutton"; import radiobutton from "openvue/radiobutton";
import inputtext from "openvue/inputtext"; import inputtext from "openvue/inputtext";
import { n as product_name_n } from "~/lib/product-name.js"; import { n as product_name_n } from "~/lib/product-name.js";
import select from "openvue/select"; import textarea from "openvue/textarea";
import RichTextEditor from "~/components/RichTextEditor.vue";
import TicketNotice from "~/components/TicketNotice.vue"; import TicketNotice from "~/components/TicketNotice.vue";
import chip from "openvue/chip"; import chip from "openvue/chip";
import multiselect from "openvue/multiselect"; import multiselect from "openvue/multiselect";
@@ -56,10 +54,6 @@ var le = {
w = { w = {
class: `field`, class: `field`,
}, },
T = {
key: 2,
class: `field`,
},
E = { E = {
class: `field`, class: `field`,
}, },
@@ -110,12 +104,10 @@ var le = {
let b = useApi(), let b = useApi(),
z = useRouter(), z = useRouter(),
B = ref([]), B = ref([]),
V = ref([]),
H = ref([]), H = ref([]),
U = reactive({ U = reactive({
title: ``, title: ``,
ticket_type_id: null, ticket_type_id: null,
admin_role_id: null,
host_ids: [], host_ids: [],
content: ``, content: ``,
}), }),
@@ -172,17 +164,14 @@ var le = {
function ge(e) { function ge(e) {
W.value || X.value.splice(e, 1); W.value || X.value.splice(e, 1);
} }
// 类型/部门加载成功后再开放提交;实例可选列表分页读取,最多 100 页,实例查询失败不阻止无关联工单创建。 // 后端以 ticket_type_id 决定部门;实例查询失败不阻止无关联工单创建。
async function Q() { async function Q() {
if (isCurrent() && !K.value) { if (isCurrent() && !K.value) {
((K.value = !0), (q.value = !1), (G.value = ``)); ((K.value = !0), (q.value = !1), (G.value = ``));
try { try {
let [e, t] = await Promise.all([ let e = await b.get(`/ticket/type`);
b.get(`/ticket/type`),
b.get(`/ticket/department`),
]);
if (!isCurrent()) return; if (!isCurrent()) return;
((B.value = e.list ?? []), (V.value = t.list ?? [])); B.value = e.list ?? [];
try { try {
let e = [], let e = [],
t = 1, t = 1,
@@ -203,9 +192,7 @@ var le = {
if (!isCurrent()) return; if (!isCurrent()) return;
H.value = []; H.value = [];
} }
((U.ticket_type_id = B.value[0]?.id ?? null), ((U.ticket_type_id = B.value[0]?.id ?? null), (q.value = !0));
(U.admin_role_id = V.value[0]?.id ?? null),
(q.value = !0));
} catch (e) { } catch (e) {
if (!isCurrent()) return; if (!isCurrent()) return;
G.value = G.value =
@@ -230,9 +217,8 @@ var le = {
try { try {
let e = await b.post(`/ticket`, { let e = await b.post(`/ticket`, {
title: U.title.trim(), title: U.title.trim(),
content: U.content.trim(), content: U.content,
ticket_type_id: U.ticket_type_id, ticket_type_id: U.ticket_type_id,
admin_role_id: U.admin_role_id,
host_ids: U.host_ids, host_ids: U.host_ids,
attachment: X.value.map((e) => e.save_name), attachment: X.value.map((e) => e.save_name),
}); });
@@ -248,16 +234,15 @@ var le = {
} }
return ( return (
onMounted(Q), onMounted(Q),
// 类型/部门选项就绪且附件上传完成后才可创建;富文本编辑器与本地附件草稿共同组成提交内容。 // 部门选项就绪且附件上传完成后才可创建;正文按客户纯文本契约原样提交。
(n, i) => { (n, i) => {
let s = TicketNotice, let s = TicketNotice,
c = button, c = button,
m = radiobutton, m = radiobutton,
_ = inputtext, _ = inputtext,
y = select,
b = chip, b = chip,
z = multiselect, z = multiselect,
J = RichTextEditor, J = textarea,
Y = message, Y = message,
_e = NuxtLink; _e = NuxtLink;
return ( return (
@@ -303,7 +288,7 @@ var le = {
(i[5] ||= createElementVNode( (i[5] ||= createElementVNode(
`legend`, `legend`,
null, null,
`工单类型`, `工单部门`,
-1, -1,
)), )),
(openBlock(!0), (openBlock(!0),
@@ -377,36 +362,6 @@ var le = {
[`modelValue`], [`modelValue`],
), ),
]), ]),
unref(V).length > 1
? (openBlock(),
createElementBlock(`div`, T, [
(i[7] ||= createElementVNode(
`label`,
{
for: `department`,
},
`服务部门`,
-1,
)),
createVNode(
y,
{
"input-id": `department`,
modelValue: unref(U).admin_role_id,
"onUpdate:modelValue": (i[2] ||= (e) =>
(unref(U).admin_role_id = e)),
options: unref(V),
optionLabel: `name`,
optionValue: `id`,
disabled: !unref(q),
fluid: ``,
},
null,
8,
[`modelValue`, `options`, `disabled`],
),
]))
: createCommentVNode(``, !0),
createElementVNode(`div`, E, [ createElementVNode(`div`, E, [
(i[8] ||= createElementVNode( (i[8] ||= createElementVNode(
`label`, `label`,
@@ -517,7 +472,9 @@ var le = {
modelValue: unref(U).content, modelValue: unref(U).content,
"onUpdate:modelValue": (i[4] ||= (e) => "onUpdate:modelValue": (i[4] ||= (e) =>
(unref(U).content = e)), (unref(U).content = e)),
label: `工单内容`, rows: 8,
autoResize: true,
fluid: true,
placeholder: `请描述问题、复现步骤和报错信息`, placeholder: `请描述问题、复现步骤和报错信息`,
disabled: unref(W), disabled: unref(W),
}, },
-1
View File
@@ -4,7 +4,6 @@
*/ */
import { definePageMeta } from "#imports"; import { definePageMeta } from "#imports";
import "~/assets/styles/TicketAttachments.DtbJGLkE.css"; import "~/assets/styles/TicketAttachments.DtbJGLkE.css";
import "~/assets/styles/RichTextEditor.NkmgPT5m.css";
import "~/assets/styles/_id_.C-0--Lx-.css"; import "~/assets/styles/_id_.C-0--Lx-.css";
definePageMeta({ definePageMeta({
key: (route) => route.fullPath, key: (route) => route.fullPath,
+423
View File
@@ -0,0 +1,423 @@
/** 工单内容/部门与账务分页/能力开关的契约回归;只控制本地 HTTP 边界。 */
import assert from "node:assert/strict";
import { test } from "node:test";
import {
appOrigin,
backendOrigin,
eventually,
login,
scenario,
} from "./helpers/console-browser.mjs";
async function reply(route, data) {
return route.fulfill({
json: { status: 200, msg: "", data },
headers: { "Access-Control-Allow-Origin": appOrigin },
});
}
async function financeLists(page) {
const paths = [
"/transaction",
"/credit",
"/withdraw",
"/order",
"/refund/pending/amount",
"/account/credit/freeze",
];
await page.route(
(url) =>
url.origin === backendOrigin &&
paths.includes(url.pathname.replace("/console/v1", "")),
(route) =>
reply(
route,
new URL(route.request().url()).pathname.endsWith(
"/refund/pending/amount",
)
? { amount: "0.00" }
: { list: [], count: 0, total_amount: "0.00" },
),
);
}
function ticket(content, replies = []) {
return {
id: 23,
ticket_num: "LOCAL-23",
title: "内容契约验收",
content,
content_format: "text",
attachment: [],
replies,
status: "待回复",
can_operate: 1,
create_time: 1730000000,
username: "audit-user",
client_id: 1,
color: "#777",
};
}
test(
"ticket creation submits literal text and its one effective department",
{ timeout: 30000 },
(t) =>
scenario(t, { loginMode: "password-only" }, async (page) => {
const content = " <b>用户代码</b>\n 第二行 &lt;tag&gt;\n尾行 ";
const writes = [];
await page.route(
(url) =>
url.origin === backendOrigin &&
url.pathname.startsWith("/console/v1/ticket"),
async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(
"/console/v1",
"",
);
if (path === "/ticket/config")
return reply(route, { ticket_notice_open: 0 });
if (["/ticket/type", "/ticket/department"].includes(path))
return reply(route, {
list: [
{ id: 1, name: "账务支持" },
{ id: 2, name: "网络支持" },
],
});
if (path === "/ticket" && request.method() === "POST") {
writes.push(request.postDataJSON());
return reply(route, { id: 23 });
}
if (path === "/ticket/23")
return reply(route, { ticket: ticket(content) });
return route.fallback();
},
);
await login(page, "/tickets/create");
await page.getByRole("radio", { name: "网络支持", exact: true }).check();
await page
.getByRole("textbox", { name: "工单标题", exact: true })
.fill("内容契约验收");
await page
.getByRole("textbox", { name: "工单内容", exact: true })
.fill(content);
await page.getByRole("button", { name: "创建工单", exact: true }).click();
await eventually(
() => writes,
(rows) => rows.length === 1,
"工单必须提交一次",
);
assert.equal(writes[0].ticket_type_id, 2);
assert.equal(writes[0].content, content);
assert.equal(Object.hasOwn(writes[0], "admin_role_id"), false);
await page.waitForURL((url) => url.pathname === "/tickets/23");
assert.equal(
await page.locator(".message-body").first().textContent(),
content,
);
return {
effectiveDepartment: 2,
contentKeptLiteral: true,
createdTickets: 1,
};
}),
);
test(
"ticket text, historical HTML, reply snapshots and reply drafts retain their declared format",
{ timeout: 30000 },
(t) =>
scenario(t, { loginMode: "password-only" }, async (page) => {
const initial = " <b>字面代码</b>\n 空格 &lt;tag&gt;";
const quoted = "<i>字面引用</i>\n下一行";
const replies = [
{
id: 11,
content: quoted,
content_format: "text",
type: "Client",
client_name: "audit-user",
create_time: 1730000001,
},
{
id: 12,
content: "<strong>历史客服排版</strong>",
content_format: "html",
type: "Admin",
admin_name: "本地客服",
create_time: 1730000002,
},
{
id: 13,
content: "快照回复",
content_format: "text",
type: "Client",
client_name: "audit-user",
create_time: 1730000003,
quote_reply_id: 11,
quote_info: {
content: quoted,
content_format: "text",
sender_name: "audit-user",
attachment: [],
},
},
];
const data = ticket(initial, replies);
const writes = [];
await page.route(
(url) =>
url.origin === backendOrigin &&
url.pathname.startsWith("/console/v1/ticket/"),
async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(
"/console/v1",
"",
);
if (path === "/ticket/config")
return reply(route, { ticket_notice_open: 0 });
if (path === "/ticket/23") return reply(route, { ticket: data });
if (path === "/ticket/23/reply") {
const body = request.postDataJSON();
writes.push(body);
data.replies.push({
id: 14,
...body,
content_format: "text",
type: "Client",
client_name: "audit-user",
create_time: 1730000004,
quote_info: {
content: quoted,
content_format: "text",
sender_name: "audit-user",
},
});
return reply(route, {});
}
return route.fallback();
},
);
await login(page, "/tickets/23");
const messages = page.locator(".thread .message");
await messages.nth(3).waitFor();
const first = messages.first().locator(".message-body");
assert.equal(await first.textContent(), initial);
assert.equal(await first.locator("b").count(), 0);
assert.equal(
await first.evaluate((node) => getComputedStyle(node).whiteSpace),
"pre-wrap",
);
assert.equal(
await messages.nth(2).locator(".message-body strong").textContent(),
"历史客服排版",
);
assert.equal(
await messages.nth(3).locator(".message-quote div").textContent(),
quoted,
);
assert.equal(
await messages.nth(3).locator(".message-quote i").count(),
0,
);
await messages
.nth(1)
.getByRole("button", { name: "引用", exact: true })
.click();
assert.equal(
await page.locator(".reply-quote > div > div").textContent(),
quoted,
);
const draft = " <script>字面错误日志</script>\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();
}
}),
);
+3 -3
View File
@@ -272,7 +272,7 @@ test(
await page await page
.getByRole("heading", { name: "审计工单2", exact: true }) .getByRole("heading", { name: "审计工单2", exact: true })
.waitFor(); .waitFor();
assert.equal((await editor.innerText()).trim(), ""); assert.equal(await editor.inputValue(), "");
await editor.fill("第二张工单的回复"); await editor.fill("第二张工单的回复");
await page.getByRole("button", { name: "提交回复", exact: true }).click(); await page.getByRole("button", { name: "提交回复", exact: true }).click();
await eventually( await eventually(
@@ -289,7 +289,7 @@ test(
await page await page
.getByRole("button", { name: "关闭工单", exact: true }) .getByRole("button", { name: "关闭工单", exact: true })
.waitFor({ state: "hidden" }); .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.locator("#reply-files").isDisabled(), true);
assert.equal( assert.equal(
await page await page
@@ -404,7 +404,7 @@ for (const [routePath, status] of [
name: "回复内容", name: "回复内容",
exact: true, 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.locator("#reply-files").isDisabled(), true);
assert.equal( assert.equal(
await page await page