794 lines
33 KiB
Vue
794 lines
33 KiB
Vue
<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>
|