refactor(ticket): move the config tab into the SPA ticket center

users.php → 工单管理 的「配置」Tab 只管理部门 / 状态 / 预设回复三块
DB 字典,与 SPA「工单中心」已有的 8 个标量配置分裂成两处;且同一套
CRUD 在 admin_post 与 TicketConfigRest 里各写了一遍,REST 侧此前
没有任何调用方。

- 删除原生 config tab:UsersMenusTicketsConfig.php 整份移除,
  UsersMenusTicketsCenter 由 512 行降到 251 行(六个写处理方法、
  分发分支、十条提示码与 ticket_delete_button 一并清理)
- SPA /users/ticket 改为 customPage:内嵌 SchemaPage 承载原有 8 字段,
  三块字典改为 n-data-table + n-modal + n-dialog,取色器/管理员多选/
  拖拽排序替代原生手填表单
- TicketConfigRest 成为三块字典的唯一实现,新增 api/modules/tickets.js
  封装 aksrt/v1/admin/tickets*
- 双向深链:原生页跳 SPA 配置页,SPA 页跳回原生工单列表

契约测试同步更新,并新增 ticket-spa-config-contract 守护接线。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-26 19:48:58 +08:00
1 parent d32bdc0581
commit e7ca0ee5df
19 files changed
+1074 -509

No files matched your search

+2
View File
@@ -6,6 +6,7 @@ import { extensionsApiMethods } from './extensions.js'
import { toolsApiMethods } from './tools.js'
import { schemaApiMethods } from './schema.js'
import { iconsApiMethods } from './icons.js'
import { ticketsApiMethods } from './tickets.js'
export const apiMethods = {
...globalApiMethods,
@@ -16,4 +17,5 @@ export const apiMethods = {
...toolsApiMethods,
...schemaApiMethods,
...iconsApiMethods,
...ticketsApiMethods,
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 工单管理端 REST(命名空间 aksrt/v1/admin)。
*
* TicketRestAdmin 把三块字典 CRUD(工单状态 / 部门 / 预设回复)挂在
* aksrt/v1/admin 下,全部要求 manage_options。api 客户端的 baseUrl 只到
* aksrt/v1,因此这里必须用 /aksrt/ 全路径形式(joinRestUrl 的
* startsWith('/aksrt/') 分支会原样拼接,不再追加 base 前缀)。
*/
const ADMIN = '/aksrt/v1/admin/tickets'
export const ticketsApiMethods = {
/* ---------------- 工单状态 ---------------- */
listTicketStatuses() { return this.request(`${ADMIN}/statuses`) },
createTicketStatus(data) {
return this.request(`${ADMIN}/statuses`, { method: 'POST', body: JSON.stringify(data) })
},
updateTicketStatus(id, data) {
return this.request(`${ADMIN}/statuses/${id}`, { method: 'POST', body: JSON.stringify(data) })
},
deleteTicketStatus(id) {
return this.request(`${ADMIN}/statuses/${id}/delete`, { method: 'POST', body: '{}' })
},
/* ---------------- 工单部门 ---------------- */
listTicketTypes() { return this.request(`${ADMIN}/types`) },
createTicketType(data) {
return this.request(`${ADMIN}/types`, { method: 'POST', body: JSON.stringify(data) })
},
updateTicketType(id, data) {
return this.request(`${ADMIN}/types/${id}`, { method: 'POST', body: JSON.stringify(data) })
},
deleteTicketType(id) {
return this.request(`${ADMIN}/types/${id}/delete`, { method: 'POST', body: '{}' })
},
/* ---------------- 预设回复 ---------------- */
listTicketPrereplies() { return this.request(`${ADMIN}/prereplies`) },
createTicketPrereply(data) {
return this.request(`${ADMIN}/prereplies`, { method: 'POST', body: JSON.stringify(data) })
},
updateTicketPrereply(id, data) {
return this.request(`${ADMIN}/prereplies/${id}`, { method: 'POST', body: JSON.stringify(data) })
},
deleteTicketPrereply(id) {
return this.request(`${ADMIN}/prereplies/${id}/delete`, { method: 'POST', body: '{}' })
},
/** 排序入参必须是全量 ID,数量不符后端直接拒绝。 */
reorderTicketPrereplies(ids) {
return this.request(`${ADMIN}/prereplies/order`, { method: 'POST', body: JSON.stringify({ ids }) })
},
}
+3 -2
View File
@@ -130,13 +130,14 @@ export const usersSection = {
canonicalDomain: 'users',
section: 'report',
}),
schemaPage({
customPage({
key: 'users-ticket',
name: 'UsersTicket',
label: '工单中心',
route: '/users/ticket',
title: '工单中心',
desc: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置;部门、状态与预设回复在 WordPress 用户菜单的「工单管理」中维护。',
desc: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置,以及部门、状态、预设回复维护;工单处理在 WordPress 用户菜单的「工单管理」中进行。',
component: 'UsersTicket',
canonicalDomain: 'users',
section: 'ticket',
}),
@@ -0,0 +1,69 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="工单中心"
desc="工单功能开关、提交公告与提交表单配置,以及部门、状态、预设回复三块字典维护。工单的日常处理在 WordPress 用户菜单的「工单管理」中完成。"
>
<template #actions>
<n-space>
<n-button quaternary @click="openNativeList">工单处理</n-button>
</n-space>
</template>
</PageHeader>
<n-tabs v-model:value="activeTab" type="segment" animated class="aksrt-ticket-tabs">
<n-tab-pane name="settings" tab="基础配置">
<SchemaPage domain="users" section="ticket" />
</n-tab-pane>
<n-tab-pane name="types" tab="工单部门">
<TicketTypePanel ref="typePanel" />
</n-tab-pane>
<n-tab-pane name="statuses" tab="工单状态">
<TicketStatusPanel ref="statusPanel" />
</n-tab-pane>
<n-tab-pane name="prereplies" tab="预设回复">
<TicketPrereplyPanel ref="prereplyPanel" />
</n-tab-pane>
</n-tabs>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { NTabPane, NTabs } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import SchemaPage from '@/pages/SchemaPage.vue'
import TicketTypePanel from './partials/ticket/TicketTypePanel.vue'
import TicketStatusPanel from './partials/ticket/TicketStatusPanel.vue'
import TicketPrereplyPanel from './partials/ticket/TicketPrereplyPanel.vue'
const activeTab = ref('settings')
const typePanel = ref(null)
const statusPanel = ref(null)
const prereplyPanel = ref(null)
/**
* 切回字典 Tab 时按需重拉:部门/状态在别处(如工单列表)不会变,
* 但多开两个后台标签时能拿到最新数据,代价只是各发一次 GET。
*/
const RELOAD_ON_ENTER = {
types: () => typePanel.value?.reload(),
statuses: () => statusPanel.value?.reload(),
prereplies: () => prereplyPanel.value?.reload(),
}
watch(activeTab, (name) => {
RELOAD_ON_ENTER[name]?.()
})
function openNativeList() {
const base = (window.aksrtAdminData?.adminUrl || '/wp-admin/').replace(/\/?$/, '/')
window.location.href = base + 'users.php?page=aksrt_tickets'
}
</script>
<style scoped>
.aksrt-ticket-tabs {
margin-bottom: 4px;
}
</style>
@@ -0,0 +1,225 @@
<template>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>预设回复</h3>
<n-space>
<n-button size="small" :disabled="saving || !reorderable" :loading="savingOrder" @click="saveOrder">
保存排序
</n-button>
<n-button size="small" type="primary" :disabled="saving" @click="openCreate">新增预设回复</n-button>
</n-space>
</div>
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px;">
预设回复是管理员回复框的一键插入话术,按下列顺序展示;拖动行调整顺序后需点「保存排序」。
</n-alert>
<n-empty v-if="!rows.length && !loading" description="暂无预设回复" />
<draggable
v-else
v-model="rows"
item-key="id"
handle=".aksrt-ticket-prereply__handle"
:animation="160"
class="aksrt-ticket-prereply"
>
<template #item="{ element, index }">
<div class="aksrt-ticket-prereply__row">
<span class="aksrt-ticket-prereply__handle" title="拖动排序">⋮⋮</span>
<span class="aksrt-ticket-prereply__index">{{ index + 1 }}</span>
<span class="aksrt-ticket-prereply__content">{{ element.content }}</span>
<n-space size="small">
<n-button size="tiny" type="primary" quaternary @click="openEdit(element)">编辑</n-button>
<n-button size="tiny" type="error" quaternary @click="handleDelete(element)">删除</n-button>
</n-space>
</div>
</template>
</draggable>
<n-modal
v-model:show="visible"
:title="editing ? '编辑预设回复' : '新增预设回复'"
preset="card"
style="width: 520px; max-width: 92vw;"
:mask-closable="false"
>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<n-form-item label="回复内容" path="content">
<n-input
v-model:value="form.content"
type="textarea"
:rows="5"
placeholder="填写可一键插入的回复话术"
maxlength="2000"
show-count
/>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button :disabled="saving" @click="visible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</n-card>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { NButton, NSpace, useDialog, useMessage } from 'naive-ui'
import draggable from 'vuedraggable'
import api from '@/api'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const saving = ref(false)
const savingOrder = ref(false)
const visible = ref(false)
const formRef = ref(null)
const editing = ref(null)
const rows = ref([])
/** 初始顺序,用于判断排序是否需要保存。 */
const baseline = ref([])
const form = ref({ content: '' })
const rules = {
content: [
{ required: true, message: '请填写预设回复内容', trigger: 'blur' },
{ max: 2000, message: '内容不能超过 2000 字', trigger: 'blur' },
],
}
const reorderable = computed(
() => rows.value.length > 1 && baseline.value.join(',') !== rows.value.map((r) => r.id).join(',')
)
async function load() {
loading.value = true
try {
const res = await api.listTicketPrereplies()
rows.value = res?.data || []
baseline.value = rows.value.map((row) => row.id)
} catch (error) {
message.error(error?.message || '加载预设回复失败')
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = null
form.value = { content: '' }
visible.value = true
}
function openEdit(row) {
editing.value = row
form.value = { content: String(row.content || '') }
visible.value = true
}
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const content = form.value.content.trim()
if (editing.value) {
await api.updateTicketPrereply(editing.value.id, { content })
message.success('预设回复已保存')
} else {
await api.createTicketPrereply({ content })
message.success('预设回复已创建')
}
visible.value = false
await load()
} catch (error) {
message.error(error?.message || '保存预设回复失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '删除预设回复',
content: '确认删除该预设回复吗?',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteTicketPrereply(row.id)
message.success('预设回复已删除')
await load()
} catch (error) {
message.error(error?.message || '删除预设回复失败')
}
},
})
}
/** 排序接口要求全量 ID,数量不符后端会拒绝。 */
async function saveOrder() {
const ids = rows.value.map((row) => row.id)
savingOrder.value = true
try {
await api.reorderTicketPrereplies(ids)
baseline.value = [...ids]
message.success('排序已保存')
} catch (error) {
message.error(error?.message || '保存排序失败')
await load()
} finally {
savingOrder.value = false
}
}
onMounted(load)
defineExpose({ reload: load })
</script>
<style scoped>
.aksrt-ticket-prereply {
display: grid;
gap: 8px;
}
.aksrt-ticket-prereply__row {
display: grid;
grid-template-columns: 20px 28px 1fr auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px var(--n-border-color, #efeff5) solid;
border-radius: 8px;
background: var(--n-color, #fff);
}
.aksrt-ticket-prereply__handle {
color: var(--aksrt-color-muted);
cursor: grab;
user-select: none;
}
.aksrt-ticket-prereply__index {
color: var(--aksrt-color-muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.aksrt-ticket-prereply__content {
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,261 @@
<template>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>工单状态</h3>
<n-button size="small" type="primary" :disabled="saving" @click="openCreate">新增状态</n-button>
</div>
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px;">
默认状态不可修改或删除;删除非默认状态前需先清空该状态下的工单。标记为「完结」的状态不可再流转。
</n-alert>
<n-empty v-if="!rows.length && !loading" description="暂无工单状态" />
<n-data-table
v-else
:columns="columns"
:data="rows"
:bordered="false"
size="small"
:loading="loading"
:scroll-x="680"
/>
<n-modal
v-model:show="visible"
:title="editing ? '编辑状态' : '新增状态'"
preset="card"
style="width: 460px; max-width: 92vw;"
:mask-closable="false"
>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<n-form-item label="状态名称" path="name">
<n-input v-model:value="form.name" placeholder="如:处理中" maxlength="60" />
</n-form-item>
<n-form-item label="状态颜色" path="color">
<n-space align="center">
<n-color-picker :value="previewColor" :swatches="SWATCHES" @update:value="setColor" />
<n-input v-model:value="form.color" style="width: 120px;" placeholder="#6b7280" />
<span class="aksrt-ticket-badge" :style="{ '--aksrt-ticket-color': previewColor }">
{{ form.name || '预览' }}
</span>
</n-space>
</n-form-item>
<n-form-item label="完结状态" path="is_closed">
<n-switch v-model:value="form.is_closed" />
<span class="aksrt-ticket-status__hint">开启后该状态视为终态,工单不可再流转</span>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button :disabled="saving" @click="visible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</n-card>
</template>
<script setup>
import { computed, h, onMounted, ref } from 'vue'
import { NButton, NSpace, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
const message = useMessage()
const dialog = useDialog()
/** 与 TicketStatusService::sanitize_color() 的取值范围保持一致。 */
const SWATCHES = [
'#6b7280', '#f59e0b', '#2271b1', '#16a34a',
'#d03050', '#7c3aed', '#0891b2', '#ea580c',
]
const loading = ref(false)
const saving = ref(false)
const visible = ref(false)
const formRef = ref(null)
const editing = ref(null)
const rows = ref([])
const form = ref({ name: '', color: '#6b7280', is_closed: false })
const rules = {
name: [
{ required: true, message: '请填写状态名称', trigger: 'blur' },
{ max: 60, message: '状态名称不能超过 60 字', trigger: 'blur' },
],
color: [
{
validator: (_rule, value) => /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '')),
message: '颜色需为 #rgb 或 #rrggbb',
trigger: 'blur',
},
],
}
const previewColor = computed(() =>
/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(form.value.color || ''))
? form.value.color
: '#6b7280'
)
/**
* 取色器在开启 alpha 时会回传 rgba() 字符串,而 TicketStatusService::sanitize_color()
* 只接受 #rgb / #rrggbb(其余静默回落灰色)。这里只接十六进制值,其余一律忽略,
* 避免「选了色却存成灰色」的静默失败。
*/
function setColor(value) {
const hex = typeof value === 'string' ? value.trim() : ''
if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) {
form.value.color = hex
}
}
const columns = computed(() => [
{ title: 'ID', key: 'id', width: 80 },
{
title: '状态',
key: 'name',
minWidth: 160,
render: (row) => h('span', { class: 'aksrt-ticket-badge', style: { '--aksrt-ticket-color': row.color } }, () => row.name),
},
{ title: '颜色值', key: 'color', width: 120 },
{
title: '完结',
key: 'is_closed',
width: 90,
render: (row) => (row.is_closed ? '是' : '否'),
},
{
title: '默认',
key: 'is_default',
width: 90,
render: (row) => (row.is_default ? '是' : '否'),
},
{
title: '标识',
key: 'status_key',
minWidth: 140,
render: (row) => h('code', null, () => row.status_key),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
disabled: row.is_default,
onClick: () => openEdit(row),
}, () => '编辑'),
h(NButton, {
size: 'tiny',
type: 'error',
quaternary: true,
disabled: row.is_default,
onClick: () => handleDelete(row),
}, () => '删除'),
]),
},
])
async function load() {
loading.value = true
try {
const res = await api.listTicketStatuses()
rows.value = res?.data || []
} catch (error) {
message.error(error?.message || '加载工单状态失败')
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = null
form.value = { name: '', color: '#6b7280', is_closed: false }
visible.value = true
}
function openEdit(row) {
editing.value = row
form.value = {
name: String(row.name || ''),
color: String(row.color || '#6b7280'),
is_closed: !!row.is_closed,
}
visible.value = true
}
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const payload = {
name: form.value.name.trim(),
color: form.value.color,
is_closed: form.value.is_closed ? 1 : 0,
}
if (editing.value) {
await api.updateTicketStatus(editing.value.id, payload)
message.success('状态已保存')
} else {
await api.createTicketStatus(payload)
message.success('状态已创建')
}
visible.value = false
await load()
} catch (error) {
message.error(error?.message || '保存状态失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '删除状态',
content: `确认删除状态「${row.name}」吗?该状态下仍有工单时将被拒绝。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteTicketStatus(row.id)
message.success('状态已删除')
await load()
} catch (error) {
message.error(error?.message || '删除状态失败')
}
},
})
}
onMounted(load)
defineExpose({ reload: load })
</script>
<style scoped>
.aksrt-ticket-status__hint {
margin-left: 10px;
color: var(--aksrt-color-muted);
font-size: 12px;
}
/* 徽章与前台 assets/css/routes/tickets.css 的 .aksrt-ticket-badge 同构。 */
.aksrt-ticket-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--aksrt-ticket-color, #6b7280) 16%, transparent);
color: var(--aksrt-ticket-color, #6b7280);
font-size: 12px;
font-weight: 600;
line-height: 1.6;
}
</style>
@@ -0,0 +1,252 @@
<template>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>工单部门</h3>
<n-button size="small" type="primary" :disabled="saving" @click="openCreate">新增部门</n-button>
</div>
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px;">
部门决定用户提交工单时的分类与默认受理人;删除前需先清空该部门下的工单。
</n-alert>
<n-empty v-if="!rows.length && !loading" description="暂无工单部门" />
<n-data-table
v-else
:columns="columns"
:data="rows"
:bordered="false"
size="small"
:loading="loading"
:scroll-x="720"
/>
<n-modal
v-model:show="visible"
:title="editing ? '编辑部门' : '新增部门'"
preset="card"
style="width: 520px; max-width: 92vw;"
:mask-closable="false"
>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<n-form-item label="部门名称" path="name">
<n-input v-model:value="form.name" placeholder="如:技术支持" maxlength="120" />
</n-form-item>
<n-form-item label="部门说明" path="description">
<n-input
v-model:value="form.description"
type="textarea"
:rows="2"
placeholder="选填,展示在用户提交工单的部门说明中"
maxlength="200"
/>
</n-form-item>
<n-form-item label="受理管理员" path="admin_ids">
<n-select
v-model:value="form.admin_ids"
multiple
filterable
remote
:options="adminOptions"
:loading="searching"
clearable
placeholder="搜索并选择受理该部门的管理员(选填)"
:fallback-option="false"
@search="handleAdminSearch"
/>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button :disabled="saving" @click="visible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</n-card>
</template>
<script setup>
import { computed, h, onMounted, ref } from 'vue'
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const saving = ref(false)
const searching = ref(false)
const visible = ref(false)
const formRef = ref(null)
const editing = ref(null)
const rows = ref([])
const adminOptions = ref([])
const form = ref({ name: '', description: '', admin_ids: [] })
const rules = {
name: [
{ required: true, message: '请填写部门名称', trigger: 'blur' },
{ max: 120, message: '部门名称不能超过 120 字', trigger: 'blur' },
],
}
const columns = computed(() => [
{ title: 'ID', key: 'id', width: 80 },
{ title: '部门名称', key: 'name', minWidth: 160 },
{
title: '说明',
key: 'description',
minWidth: 220,
render: (row) => (row.description ? row.description : '—'),
},
{
title: '受理管理员',
key: 'admin_ids',
minWidth: 200,
render: (row) => {
const ids = Array.isArray(row.admin_ids) ? row.admin_ids : []
if (!ids.length) return '—'
return h(NSpace, { size: 4, wrap: true }, () =>
ids.map((id) =>
h(NTag, { size: 'small', round: true, bordered: false }, () => (adminName(id) || `#${id}`))
)
)
},
},
{
title: '操作',
key: 'actions',
width: 130,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openEdit(row),
}, () => '编辑'),
h(NButton, {
size: 'tiny',
type: 'error',
quaternary: true,
onClick: () => handleDelete(row),
}, () => '删除'),
]),
},
])
function adminName(id) {
const hit = adminOptions.value.find((opt) => Number(opt.value) === Number(id))
return hit ? hit.label : ''
}
async function load() {
loading.value = true
try {
const res = await api.listTicketTypes()
rows.value = res?.data || []
} catch (error) {
message.error(error?.message || '加载工单部门失败')
} finally {
loading.value = false
}
}
let searchToken = 0
async function handleAdminSearch(query) {
const keyword = String(query || '').trim()
const token = ++searchToken
searching.value = true
try {
const res = await api.searchUsers(keyword, 20)
if (token !== searchToken) return
adminOptions.value = (res?.data || res || []).map((user) => ({
label: user.display_name || user.user_login || `#${user.id}`,
value: Number(user.id),
}))
} catch (error) {
message.error(error?.message || '搜索管理员失败')
} finally {
if (token === searchToken) searching.value = false
}
}
/** 已受理的管理员若不在当前选项里,补齐成占位项,避免回显成裸 ID。 */
function ensureAdminOptions(ids) {
for (const id of ids) {
const numeric = Number(id)
if (!adminOptions.value.some((opt) => Number(opt.value) === numeric)) {
adminOptions.value.push({ label: `#${numeric}`, value: numeric })
}
}
}
function openCreate() {
editing.value = null
form.value = { name: '', description: '', admin_ids: [] }
visible.value = true
}
function openEdit(row) {
editing.value = row
form.value = {
name: String(row.name || ''),
description: String(row.description || ''),
admin_ids: (Array.isArray(row.admin_ids) ? row.admin_ids : []).map(Number),
}
ensureAdminOptions(form.value.admin_ids)
visible.value = true
}
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const payload = {
name: form.value.name.trim(),
description: form.value.description.trim(),
admin_ids: form.value.admin_ids.map(Number),
}
if (editing.value) {
await api.updateTicketType(editing.value.id, payload)
message.success('部门已保存')
} else {
await api.createTicketType(payload)
message.success('部门已创建')
}
visible.value = false
await load()
} catch (error) {
message.error(error?.message || '保存部门失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '删除部门',
content: `确认删除部门「${row.name}」吗?该部门下仍有工单时将被拒绝。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteTicketType(row.id)
message.success('部门已删除')
await load()
} catch (error) {
message.error(error?.message || '删除部门失败')
}
},
})
}
onMounted(load)
defineExpose({ reload: load })
</script>
@@ -3,4 +3,5 @@ export const usersPageLoaders = {
UsersLevel: () => import('@/pages/users/UsersLevel.vue'),
UsersBadge: () => import('@/pages/users/UsersBadge.vue'),
UsersWechatTemplate: () => import('@/pages/users/UsersWechatTemplate.vue'),
UsersTicket: () => import('@/pages/users/UsersTicket.vue'),
}
@@ -8,7 +8,7 @@ export const ticketSchema = {
key: 'users.ticket',
title: '工单中心',
icon: 'fa fas fa-headset',
description: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置。部门、状态与预设回复在「用户 → 工单管理」中维护。',
description: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置;部门、状态与预设回复在本页其余分页中维护。',
defaults: {
ticket_s: true,
ticket_num_prefix: 'T',