Files
cieanyau 651c36367f
前端验证 / verify (push) Failing after 15m2s
接入 ESLint 与 Prettier 并加入 CI 静态门禁
2026-11-07 01:24:36 +08:00

796 lines
34 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
/**
* 工单详情:把首帖与回复按时间组成对话,支持引用、附件上传、回复、催单与关闭;附件使用服务端保存标识提交。
*/
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,
});
import { toDisplayString } from "vue";
import { renderList } from "vue";
import { onMounted } from "vue";
import { normalizeClass } from "vue";
import { createBlock } from "vue";
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";
import { createTextVNode } from "vue";
import { unref } from "vue";
import { defineComponent } from "vue";
import { createVNode } from "vue";
import { openBlock } from "vue";
import { normalizeStyle } from "vue";
import { useRoute } from "#app";
import { NuxtLink } from "#components";
import { useApi } from "~/lib/auth.js";
import button from "primevue/button";
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
import message from "primevue/message";
import { r as dict_r } from "~/lib/dict.js";
import skeleton from "primevue/skeleton";
import { n as sanitize_n } from "~/lib/sanitize.js";
import RichTextEditor from "~/components/RichTextEditor.vue";
import { n as ticketAttachments_n } from "~/lib/ticketAttachments.js";
import TicketAttachments from "~/components/TicketAttachments.vue";
import TicketNotice from "~/components/TicketNotice.vue";
import { t as composables_t } from "~/lib/composables-2.js";
var le = {
class: `page`,
},
ue = {
class: `page-head`,
},
de = {
key: 0,
class: `panel`,
},
fe = {
class: `panel head-panel`,
},
pe = {
class: `ticket-head`,
},
me = {
class: `ticket-title`,
},
ge = {
class: `ticket-meta`,
},
w = {
class: `panel thread-panel`,
},
T = {
class: `thread`,
},
E = {
class: `message-head`,
},
D = {
class: `sender`,
},
O = {
key: 0,
class: `badge`,
},
k = {
class: `time`,
},
A = [`innerHTML`],
j = {
key: 0,
class: `message-quote`,
},
M = [`innerHTML`],
N = {
class: `reply-box`,
},
P = {
key: 0,
class: `reply-quote`,
role: `status`,
},
F = [`innerHTML`],
I = {
class: `reply-attachments`,
},
L = {
class: `reply-upload`,
for: `reply-files`,
},
_e = [`disabled`],
R = {
key: 0,
class: `muted`,
},
ve = {
key: 1,
class: `reply-file-list`,
},
ye = {
class: `reply-actions`,
},
be = {
class: `buttons`,
},
z = _plugin_vue_export_helper_t(
defineComponent({
__name: `[id]`,
setup(m) {
let y = useRoute(),
z = useApi(),
B = String(y.params.id ?? y.query.id ?? ``);
composables_t({
key: (e) => e.fullPath,
});
let V = ref(null),
H = ref(!0),
U = ref(``),
W = ref(``),
G = ref(!1),
K = ref(!1),
q = ref([]),
J = ref(!1),
Y = ref(0);
// status 是可自定义的展示文案;所有变更入口只使用服务端操作权限。
const canOperate = computed(() => V.value?.can_operate === 1);
// 读取工单并确认实际存在;后续回复/催单/关闭完成也复用此入口同步服务端状态。
async function X() {
try {
let e = await z.get(`/ticket/${B}`);
if (!e.ticket || !e.ticket.id) {
U.value = `工单不存在或已删除`;
return;
}
V.value = e.ticket;
} catch (e) {
U.value = e instanceof Error ? e.message : `加载失败`;
} finally {
H.value = !1;
}
}
let Z = computed(() => {
let e = V.value;
return e
? [
{
id: 0,
content: e.content,
attachment: e.attachment || [],
create_time: e.create_time,
type: `Client`,
client_name: e.username,
admin_name: ``,
client_id: e.client_id,
quote_reply_id: 0,
quote_info: null,
},
...(e.replies || []).filter((e) => e.id !== 0),
]
.sort((e, t) => e.create_time - t.create_time)
.map((e) => ({
...e,
files: ticketAttachments_n(e.attachment),
}))
: [];
});
// 按回复发送方显示客服或客户名称,不依赖消息顺序推断身份。
function Q(e) {
return e.type === `Admin`
? e.admin_name || `客服`
: e.client_name || `我`;
}
let $ = computed(() =>
Y.value > 0 ? Z.value.find((e) => e.id === Y.value) : void 0,
);
// 工单允许操作时逐个上传并检查 save_name,成功部分保留在当前附件草稿。
async function xe(e) {
if (J.value || G.value || !canOperate.value) return;
let t = e.target,
n = Array.from(t.files ?? []);
if (n.length) {
((J.value = !0), (U.value = ``));
try {
for (let e of n) {
let t = await z.upload(e);
if (!canOperate.value) return;
if (!t?.save_name)
throw Error(`附件「${e.name}」上传失败,请重新选择文件`);
q.value.push({
save_name: t.save_name,
name: e.name,
});
}
} catch (e) {
U.value = e instanceof Error ? e.message : `附件上传失败`;
} finally {
((J.value = !1), (t.value = ``));
}
}
}
// 尚未提交回复时可移除本地附件项,不直接删除历史工单附件。
function Se(e) {
G.value || q.value.splice(e, 1);
}
// 仅允许引用真实回复 ID;首帖 ID 为 0 不设置为引用目标。
function Ce(e) {
e.id > 0 && canOperate.value && (Y.value = e.id);
}
// 引用已删除时明确提示;否则优先用服务端引用快照,再从当前对话寻找原文。
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}(内容暂不可用)`;
}
// 引用发送者优先来自引用快照,缺失时回查当前对话。
function Te(e) {
let t = e.quote_info,
n = Z.value.find((t) => t.id === e.quote_reply_id);
return (
t?.sender_name ||
t?.admin_name ||
t?.client_name ||
(n ? Q(n) : `回复 #${e.quote_reply_id}`)
);
}
// 已删除引用不再显示附件;其他引用统一交给附件解析工具处理链接。
function Ee(e) {
return Number(e.quote_info?.is_deleted) === 1
? []
: ticketAttachments_n(
e.quote_info?.attachment ||
Z.value.find((t) => t.id === e.quote_reply_id)?.attachment ||
[],
);
}
// 回复要求内容非空、附件已上传且服务端允许操作;成功后清空草稿与引用并重读工单。
async function De() {
if (!(G.value || !W.value.trim() || J.value || !canOperate.value)) {
G.value = !0;
try {
(await z.post(`/ticket/${B}/reply`, {
content: W.value.trim(),
attachment: q.value.map((e) => e.save_name),
quote_reply_id: Y.value,
}),
(W.value = ``),
(q.value = []),
(Y.value = 0),
await X());
} catch (e) {
U.value = e instanceof Error ? e.message : `回复失败`;
} finally {
G.value = !1;
}
}
}
// 催单后重新读取工单,结果与可继续操作状态以服务端为准。
async function Oe() {
if (!canOperate.value) return;
K.value = !0;
try {
(await z.put(`/ticket/${B}/urge`), await X());
} catch (e) {
U.value = e instanceof Error ? e.message : `催单失败`;
} finally {
K.value = !1;
}
}
// 关闭工单完成后重读详情,由服务端权限控制回复与上传入口。
async function ke() {
if (!canOperate.value) return;
K.value = !0;
try {
(await z.put(`/ticket/${B}/close`), await X());
} catch (e) {
U.value = e instanceof Error ? e.message : `关闭失败`;
} finally {
K.value = !1;
}
}
return (
onMounted(X),
// 对话按时间展示并使用公共 HTML 清理与附件解析;引用删除有独立文案,只读工单不开放回复编辑。
(n, a) => {
let c = TicketNotice,
m = NuxtLink,
_ = skeleton,
v = message,
y = button,
S = TicketAttachments,
C = RichTextEditor;
return (
openBlock(),
createElementBlock(`div`, le, [
createVNode(c),
createElementVNode(`div`, ue, [
createVNode(
m,
{
to: `/tickets`,
class: `back`,
},
{
default: withCtx(() => [
...(a[2] ||= [
createElementVNode(
`i`,
{
class: `pi pi-arrow-left`,
},
null,
-1,
),
createTextVNode(` 返回工单列表 `, -1),
]),
]),
_: 1,
},
),
]),
unref(H)
? (openBlock(),
createElementBlock(`div`, de, [
createVNode(_, {
height: `220px`,
}),
]))
: unref(U) && !unref(V)
? (openBlock(),
createBlock(
v,
{
key: 1,
severity: `error`,
closable: !1,
},
{
default: withCtx(() => [
createTextVNode(toDisplayString(unref(U)), 1),
]),
_: 1,
},
))
: unref(V)
? (openBlock(),
createElementBlock(
Fragment,
{
key: 2,
},
[
createElementVNode(`div`, fe, [
createElementVNode(`div`, pe, [
createElementVNode(`div`, me, [
createElementVNode(
`h2`,
null,
toDisplayString(unref(V).title),
1,
),
createElementVNode(
`span`,
{
class: `status-tag`,
style: normalizeStyle({
color: `var(--p-text-color)`,
borderColor: unref(V).color,
}),
},
toDisplayString(unref(V).status),
5,
),
]),
!unref(canOperate)
? createCommentVNode(``, !0)
: (openBlock(),
createBlock(
y,
{
key: 0,
label: `关闭工单`,
severity: `danger`,
outlined: ``,
size: `small`,
loading: unref(K),
onClick: ke,
},
null,
8,
[`loading`],
)),
]),
createElementVNode(`div`, ge, [
createElementVNode(
`span`,
null,
`#` +
toDisplayString(unref(V).ticket_num) +
` · 提交于 ` +
toDisplayString(
(`formatTime` in n
? n.formatTime
: unref(dict_r))(unref(V).create_time),
),
1,
),
]),
]),
createElementVNode(`div`, w, [
createElementVNode(`div`, T, [
(openBlock(!0),
createElementBlock(
Fragment,
null,
renderList(
unref(Z),
(t, a) => (
openBlock(),
createElementBlock(
`div`,
{
key: `${t.id}-${a}`,
class: normalizeClass([
`message`,
{
admin: t.type === `Admin`,
},
]),
},
[
createElementVNode(`div`, E, [
createElementVNode(
`span`,
{
class: normalizeClass([
`sender-avatar`,
{
staff: t.type === `Admin`,
},
]),
},
toDisplayString(Q(t).slice(0, 1)),
3,
),
createElementVNode(
`span`,
D,
toDisplayString(Q(t)),
1,
),
t.type === `Admin`
? (openBlock(),
createElementBlock(
`span`,
O,
`客服`,
))
: createCommentVNode(``, !0),
createElementVNode(
`span`,
k,
toDisplayString(
(`formatTime` in n
? n.formatTime
: unref(dict_r))(
t.create_time,
),
),
1,
),
]),
createElementVNode(
`div`,
{
class: `message-body`,
innerHTML: (`sanitizeHtml` in n
? n.sanitizeHtml
: unref(sanitize_n))(t.content),
},
null,
8,
A,
),
t.quote_reply_id
? (openBlock(),
createElementBlock(`div`, j, [
createElementVNode(
`span`,
null,
`引用 ` +
toDisplayString(Te(t)) +
`:`,
1,
),
createElementVNode(
`div`,
{
innerHTML:
(`sanitizeHtml` in n
? n.sanitizeHtml
: unref(sanitize_n))(
we(t),
),
},
null,
8,
M,
),
createVNode(
S,
{
files: Ee(t),
},
null,
8,
[`files`],
),
]))
: createCommentVNode(``, !0),
createVNode(
S,
{
files: t.files,
},
null,
8,
[`files`],
),
t.id > 0 && unref(canOperate)
? (openBlock(),
createBlock(
y,
{
key: 1,
label: `引用`,
icon: `pi pi-reply`,
text: ``,
size: `small`,
class: `quote-button`,
onClick: (e) => Ce(t),
},
null,
8,
[`onClick`],
))
: createCommentVNode(``, !0),
],
2,
)
),
),
128,
)),
]),
createElementVNode(`div`, N, [
unref($)
? (openBlock(),
createElementBlock(`div`, P, [
createElementVNode(`div`, null, [
createElementVNode(
`span`,
null,
`引用 ` +
toDisplayString(Q(unref($))) +
`:`,
1,
),
createElementVNode(
`div`,
{
innerHTML: (`sanitizeHtml` in n
? n.sanitizeHtml
: unref(sanitize_n))(
unref($).content,
),
},
null,
8,
F,
),
]),
createVNode(y, {
icon: `pi pi-times`,
text: ``,
rounded: ``,
size: `small`,
"aria-label": `取消引用`,
onClick: (a[0] ||= (e) =>
(Y.value = 0)),
}),
]))
: createCommentVNode(``, !0),
createVNode(
C,
{
modelValue: unref(W),
"onUpdate:modelValue": (a[1] ||= (e) =>
isRef(W) ? (W.value = e) : null),
label: `回复内容`,
"min-height": 140,
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,
},
8,
[`modelValue`, `disabled`],
),
createElementVNode(`div`, I, [
unref(J)
? (openBlock(),
createElementBlock(
`span`,
R,
`正在上传…`,
))
: createCommentVNode(``, !0),
unref(q).length
? (openBlock(),
createElementBlock(`ul`, ve, [
(openBlock(!0),
createElementBlock(
Fragment,
null,
renderList(
unref(q),
(t, n) => (
openBlock(),
createElementBlock(
`li`,
{
key: t.save_name,
},
[
createElementVNode(
`span`,
null,
toDisplayString(t.name),
1,
),
createVNode(
y,
{
icon: `pi pi-times`,
text: ``,
rounded: ``,
size: `small`,
"aria-label": `移除附件 ${t.name}`,
disabled: unref(G),
onClick: (e) => Se(n),
},
null,
8,
[
`aria-label`,
`disabled`,
`onClick`,
],
),
],
)
),
),
128,
)),
]))
: createCommentVNode(``, !0),
]),
createElementVNode(`div`, ye, [
unref(U)
? (openBlock(),
createBlock(
v,
{
key: 0,
severity: `error`,
closable: !1,
class: `inline-error`,
},
{
default: withCtx(() => [
createTextVNode(
toDisplayString(unref(U)),
1,
),
]),
_: 1,
},
))
: createCommentVNode(``, !0),
createElementVNode(`div`, be, [
!unref(canOperate)
? createCommentVNode(``, !0)
: (openBlock(),
createBlock(
y,
{
key: 0,
label: `催单`,
severity: `secondary`,
outlined: ``,
size: `small`,
loading: unref(K),
onClick: Oe,
},
null,
8,
[`loading`],
)),
createVNode(
y,
{
label: `提交回复`,
loading: unref(G),
disabled:
!unref(canOperate) ||
!unref(W).trim() ||
unref(J),
onClick: De,
},
null,
8,
[`loading`, `disabled`],
),
]),
]),
]),
]),
],
64,
))
: createCommentVNode(``, !0),
])
);
}
);
},
}),
[[`__scopeId`, `data-v-3743a20f`]],
);
export {};
export default z;
</script>