Files
console-dev/app/pages/tickets/[id].vue
T

794 lines
33 KiB
Vue
Raw 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 "~/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`,
},
he = {
class: `ticket-num`,
},
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);
// 读取工单并确认实际存在;后续回复/催单/关闭完成也复用此入口同步服务端状态。
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(() => Z.value.find((e) => e.id === Y.value));
// 关闭工单不允许上传;逐个上传并检查 save_name,成功部分保留在当前附件草稿。
async function xe(e) {
if (J.value || G.value || V.value?.status === `已关闭`) 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 (!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 && V.value?.status !== `已关闭` && (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 ||
V.value?.status === `已关闭`
)) {
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() {
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() {
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(
`span`,
he,
`#` + toDisplayString(unref(V).ticket_num),
1,
),
createElementVNode(
`h2`,
null,
toDisplayString(unref(V).title),
1,
),
]),
unref(V).status === `已关闭`
? createCommentVNode(``, !0)
: (openBlock(),
createBlock(
y,
{
key: 0,
label: `关闭工单`,
severity: `danger`,
outlined: ``,
size: `small`,
loading: unref(K),
onClick: ke,
},
null,
8,
[`loading`],
)),
createElementVNode(
`span`,
{
class: `status-tag`,
style: normalizeStyle({
color: `var(--p-text-color)`,
borderColor: unref(V).color,
}),
},
toDisplayString(unref(V).status),
5,
),
]),
createElementVNode(`div`, ge, [
createElementVNode(
`span`,
null,
`提交时间:` +
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(V).status !== `已关闭`
? (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(V).status === `已关闭`,
},
null,
8,
[`modelValue`, `disabled`],
),
createElementVNode(`div`, I, [
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(V).status === `已关闭`,
onChange: xe,
},
null,
40,
_e,
),
]),
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(V).status === `已关闭`
? 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(V).status === `已关闭` ||
!unref(W).trim() ||
unref(J),
onClick: De,
},
null,
8,
[`loading`, `disabled`],
),
]),
]),
]),
]),
],
64,
))
: createCommentVNode(``, !0),
])
);
}
);
},
}),
[[`__scopeId`, `data-v-3743a20f`]],
);
export {};
export default z;
</script>