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

805 lines
34 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 { definePageMeta } from "#imports";
import "~/assets/styles/TicketAttachments.DtbJGLkE.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 { onScopeDispose } 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 { 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, useAuth } from "~/lib/auth.js";
import button from "openvue/button";
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
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 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";
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`,
},
j = {
key: 0,
class: `message-quote`,
},
N = {
class: `reply-box`,
},
P = {
key: 0,
class: `reply-quote`,
role: `status`,
},
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) {
const session = useAuth().prepareLogin();
let disposed = false;
const isCurrent = () => !disposed && session.isCurrent();
onScopeDispose(() => {
disposed = true;
});
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() {
if (!isCurrent()) return;
try {
let e = await z.get(`/ticket/${B}`);
if (!isCurrent()) return;
if (!e.ticket || !e.ticket.id) {
U.value = `工单不存在或已删除`;
return;
}
V.value = e.ticket;
} catch (e) {
if (isCurrent())
U.value = e instanceof Error ? e.message : `加载失败`;
} finally {
if (isCurrent()) H.value = !1;
}
}
let Z = computed(() => {
let e = V.value;
return e
? [
{
id: 0,
content: e.content,
content_format: e.content_format,
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 (!isCurrent() || 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) {
if (!isCurrent() || !canOperate.value) return;
let t = await z.upload(e);
if (!isCurrent() || !canOperate.value) return;
if (!t?.save_name)
throw Error(`附件「${e.name}」上传失败,请重新选择文件`);
q.value.push({
save_name: t.save_name,
name: e.name,
});
}
} catch (e) {
if (isCurrent())
U.value = e instanceof Error ? e.message : `附件上传失败`;
} finally {
if (isCurrent()) ((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);
}
// 正文和引用共用格式边界:纯文本不经过 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) {
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) {
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 (
isCurrent() &&
!(G.value || !W.value.trim() || J.value || !canOperate.value)
) {
G.value = !0;
try {
await z.post(`/ticket/${B}/reply`, {
content: W.value,
attachment: q.value.map((e) => e.save_name),
quote_reply_id: Y.value,
});
if (!isCurrent()) return;
W.value = ``;
q.value = [];
Y.value = 0;
await X();
} catch (e) {
if (isCurrent())
U.value = e instanceof Error ? e.message : `回复失败`;
} finally {
if (isCurrent()) G.value = !1;
}
}
}
// 催单后重新读取工单,结果与可继续操作状态以服务端为准。
async function Oe() {
if (!isCurrent() || !canOperate.value) return;
K.value = !0;
try {
await z.put(`/ticket/${B}/urge`);
if (isCurrent()) await X();
} catch (e) {
if (isCurrent())
U.value = e instanceof Error ? e.message : `催单失败`;
} finally {
if (isCurrent()) K.value = !1;
}
}
// 关闭工单完成后重读详情,由服务端权限控制回复与上传入口。
async function ke() {
if (!isCurrent() || !canOperate.value) return;
K.value = !0;
try {
await z.put(`/ticket/${B}/close`);
if (isCurrent()) await X();
} catch (e) {
if (isCurrent())
U.value = e instanceof Error ? e.message : `关闭失败`;
} finally {
if (isCurrent()) K.value = !1;
}
}
return (
onMounted(X),
// 对话遵循服务端内容格式;客户回复使用纯文本,只读工单不开放编辑。
(n, a) => {
let c = TicketNotice,
m = NuxtLink,
_ = skeleton,
v = message,
y = button,
S = TicketAttachments,
C = textarea;
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`,
...contentProps(t),
},
null,
16,
),
t.quote_reply_id
? (openBlock(),
createElementBlock(`div`, j, [
createElementVNode(
`span`,
null,
`引用 ` +
toDisplayString(Te(t)) +
`:`,
1,
),
createElementVNode(
`div`,
{
...contentProps(we(t)),
},
null,
16,
),
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`,
{
...contentProps(unref($)),
},
null,
16,
),
]),
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) =>
(W.value = e)),
"aria-label": `回复内容`,
rows: 6,
autoResize: true,
fluid: true,
placeholder: `输入回复内容…`,
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(),
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>