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',
+17 -278
View File
@@ -2,12 +2,11 @@
/**
* 工单管理后台页(users.php → aksrt_tickets)。
*
* 两个 Tab:
* 1. tickets — 工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)
* 2. config — 配置:部门 / 状态 / 预设回复
* 单页承载工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)。
* 部门、状态与预设回复的配置已迁至主题后台 SPA「工单中心」(/users/ticket),
* 由 TicketConfigRest 提供 REST 实现。
*
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致;前端 JS 见
* assets/js/admin-ticket-center.js。
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致。
*
* @package AKSRT
*/
@@ -19,7 +18,6 @@ defined('ABSPATH') || exit;
trait UsersMenusTicketsCenter {
use UsersMenusTicketsList;
use UsersMenusTicketsConfig;
private const TICKET_PAGE_SLUG = 'aksrt_tickets';
private const TICKET_PER_PAGE = 20;
@@ -38,30 +36,18 @@ trait UsersMenusTicketsCenter {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'tickets';
$tab = $tab === 'config' ? 'config' : 'tickets';
echo '<div class="wrap">';
echo '<h1>' . esc_html__('工单管理', 'aksrt') . '</h1>';
printf(
'<p><a class="button" href="%s">%s</a></p>',
esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')),
esc_html__('工单配置(部门 / 状态 / 预设回复)', 'aksrt')
);
echo '<style>' . $this->ticket_page_css() . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$this->render_ticket_notices();
echo '<h2 class="nav-tab-wrapper">';
foreach (['tickets' => __('工单列表', 'aksrt'), 'config' => __('配置', 'aksrt')] as $key => $label) {
printf(
'<a href="%s" class="nav-tab %s">%s</a>',
esc_url(admin_url('users.php?page=' . self::TICKET_PAGE_SLUG . '&tab=' . $key)),
$tab === $key ? 'nav-tab-active' : '',
esc_html($label)
);
}
echo '</h2>';
if ($tab === 'config') {
$this->render_ticket_config_tab();
} else {
$this->render_ticket_list_tab();
}
$this->render_ticket_list_tab();
echo '</div>';
}
@@ -76,16 +62,6 @@ trait UsersMenusTicketsCenter {
'failed' => __('工单状态更新失败:当前状态不允许该操作。', 'aksrt'),
'replied' => __('回复已提交。', 'aksrt'),
'reply_failed' => __('回复提交失败。', 'aksrt'),
'type_saved' => __('部门已保存。', 'aksrt'),
'type_deleted' => __('部门已删除。', 'aksrt'),
'type_in_use' => __('该部门下还有工单,无法删除。', 'aksrt'),
'status_saved' => __('状态已保存。', 'aksrt'),
'status_deleted' => __('状态已删除。', 'aksrt'),
'status_default' => __('默认状态不可修改或删除。', 'aksrt'),
'status_in_use' => __('该状态下还有工单,无法删除。', 'aksrt'),
'prereply_saved' => __('预设回复已保存。', 'aksrt'),
'prereply_deleted' => __('预设回复已删除。', 'aksrt'),
'prereply_order_failed' => __('预设回复排序失败:需要提交全部条目。', 'aksrt'),
'note_saved' => __('备注已保存。', 'aksrt'),
'note_deleted' => __('备注已删除。', 'aksrt'),
'created' => __('工单已创建。', 'aksrt'),
@@ -100,6 +76,8 @@ trait UsersMenusTicketsCenter {
/**
* 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位,
* 与 PayOrders 的行内处理表单同模式。
*
* 部门 / 状态 / 预设回复的写操作已迁至 SPA「工单中心」(TicketConfigRest)。
*/
public function handle_ticket_update(): void {
if (!current_user_can(self::CAPABILITY)) {
@@ -117,24 +95,6 @@ trait UsersMenusTicketsCenter {
case 'reply':
$code = $this->ticket_action_reply($id);
break;
case 'type':
$code = $this->ticket_action_type();
break;
case 'status_def':
$code = $this->ticket_action_status_def();
break;
case 'prereply':
$code = $this->ticket_action_prereply();
break;
case 'prereply_delete':
$code = $this->ticket_action_prereply_delete();
break;
case 'type_delete':
$code = $this->ticket_action_type_delete();
break;
case 'status_delete':
$code = $this->ticket_action_status_delete();
break;
case 'note':
$code = $this->ticket_action_note($id);
break;
@@ -143,11 +103,10 @@ trait UsersMenusTicketsCenter {
break;
}
$args = ['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code];
if (isset($_POST['ticket_tab'])) {
$args['tab'] = sanitize_key((string) wp_unslash($_POST['ticket_tab']));
}
wp_safe_redirect(add_query_arg($args, admin_url('users.php')));
wp_safe_redirect(add_query_arg(
['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code],
admin_url('users.php')
));
exit;
}
@@ -209,204 +168,6 @@ trait UsersMenusTicketsCenter {
return 'replied';
}
/** @return string 提示码 */
private function ticket_action_type(): string {
if (!class_exists('\AKSRT\Ticket\TicketTypeService')) {
return 'failed';
}
$id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0;
$name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
$name = trim($name);
$description = isset($_POST['description']) ? sanitize_text_field((string) wp_unslash($_POST['description'])) : '';
$admin_ids = isset($_POST['admin_ids']) ? array_map('absint', (array) $_POST['admin_ids']) : [];
if ($name === '' || \AKSRT\Ticket\TicketTypeService::name_taken($name, $id)) {
return 'failed';
}
$payload = [
'name' => $name,
'description' => $description,
'admin_ids' => \AKSRT\Ticket\TicketTypeService::sanitize_admin_ids($admin_ids),
];
if ($id > 0) {
if (!\AKSRT\Ticket\TicketTypeService::exists($id)) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
$saved = $wpdb->update($table, [
'name' => $payload['name'],
'description' => $payload['description'],
'admin_ids' => wp_json_encode($payload['admin_ids']),
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%s', '%s'], ['%d']);
if ($saved === false) {
return 'failed';
}
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_saved';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return 'failed';
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'name' => $payload['name'],
'description' => $payload['description'],
'admin_ids' => wp_json_encode($payload['admin_ids']),
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
return 'failed';
}
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_saved';
}
/** @return string 提示码 */
private function ticket_action_status_def(): string {
if (!class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return 'failed';
}
$id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0;
$name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
$name = trim($name);
$color = \AKSRT\Ticket\TicketStatusService::sanitize_color(
(string) wp_unslash($_POST['color'] ?? '')
);
$is_closed = isset($_POST['is_closed']) ? 1 : 0;
if ($name === '') {
return 'failed';
}
if ($id > 0) {
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
if (!is_array($row)) {
return 'failed';
}
if (!empty($row['is_default'])) {
return 'status_default';
}
$saved = $wpdb->update($table, [
'name' => $name,
'color' => $color,
'is_closed' => $is_closed,
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
if ($saved === false) {
return 'failed';
}
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_saved';
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'status_key' => \AKSRT\Ticket\TicketStatusService::make_key($name),
'name' => $name,
'color' => $color,
'is_closed' => $is_closed,
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%d', '%s']);
if ($inserted !== 1) {
return 'failed';
}
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_saved';
}
/** @return string 提示码 */
private function ticket_action_prereply(): string {
if (!class_exists('\AKSRT\Ticket\TicketPrereplyService')) {
return 'failed';
}
$id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0;
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '') {
return 'failed';
}
if ($id > 0) {
if (\AKSRT\Ticket\TicketPrereplyService::find($id) === null) {
return 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::update($id, $content) ? 'prereply_saved' : 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::create($content) > 0 ? 'prereply_saved' : 'failed';
}
/** @return string 提示码 */
private function ticket_action_prereply_delete(): string {
$id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketPrereplyService')) {
return 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::delete($id) ? 'prereply_deleted' : 'failed';
}
/** @return string 提示码 */
private function ticket_action_type_delete(): string {
$id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketTypeService')) {
return 'failed';
}
if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_type($id) > 0) {
return 'type_in_use';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return 'failed';
}
$wpdb->delete($table, ['id' => $id], ['%d']);
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_deleted';
}
/** @return string 提示码 */
private function ticket_action_status_delete(): string {
$id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return 'failed';
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
if (!is_array($row)) {
return 'failed';
}
if (!empty($row['is_default'])) {
return 'status_default';
}
$key = sanitize_key((string) $row['status_key']);
if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_status($key) > 0) {
return 'status_in_use';
}
$wpdb->delete($table, ['id' => $id], ['%d']);
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_deleted';
}
/** @return string 提示码 */
private function ticket_action_note(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) {
return 'failed';
@@ -471,29 +232,7 @@ trait UsersMenusTicketsCenter {
);
}
/**
* 行内删除按钮:独立小表单 + 二次确认,nonce 以 action + id 为单位。
* 删除类操作不占用列表主表单,避免误删。
*/
private function ticket_delete_button(string $action, string $field, int $id, string $label): string {
return sprintf(
'<form method="post" action="%s" style="display:inline;" onsubmit="return confirm(\'%s\');">'
. '<input type="hidden" name="action" value="aksrt_ticket_update" />'
. '<input type="hidden" name="ticket_action" value="%s" />'
. '<input type="hidden" name="ticket_tab" value="config" />'
. '<input type="hidden" name="%s" value="%d" />%s'
. '<button type="submit" class="button-link-delete">%s</button></form>',
esc_url(admin_url('admin-post.php')),
esc_js(__('确认删除?', 'aksrt')),
esc_attr($action),
esc_attr($field),
$id,
wp_nonce_field('aksrt_ticket_' . $action . '_' . $id, '_wpnonce', true, false),
esc_html($label)
);
}
/** 工单页共享样式(徽章取状态颜色,两个 Tab 都用)。 */
/** 工单页共享样式(徽章取状态颜色)。 */
private function ticket_page_css(): string {
return '.aksrt-ticket-badge{display:inline-block;padding:2px 10px;border-radius:999px;'
. 'background:color-mix(in srgb,var(--aksrt-ticket-color,#6b7280) 14%,transparent);'
@@ -1,165 +0,0 @@
<?php
/**
* 工单管理后台页 — 配置 Tab 渲染(部门 / 状态 / 预设回复)。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusTicketsConfig {
private function render_ticket_config_tab(): void {
$this->render_ticket_types_panel();
$this->render_ticket_statuses_panel();
$this->render_ticket_prereplies_panel();
}
/** 部门:表格 + 行内编辑 + 新建表单。 */
private function render_ticket_types_panel(): void {
$types = \AKSRT\Ticket\TicketTypeService::all();
$edit_id = isset($_GET['type_id']) ? absint($_GET['type_id']) : 0;
$editing = null;
foreach ($types as $row) {
if ((int) $row['id'] === $edit_id) {
$editing = $row;
break;
}
}
echo '<h3>' . esc_html__('工单部门', 'aksrt') . '</h3>';
echo '<table class="widefat fixed striped posts" style="max-width:900px;">';
echo '<thead><tr><th width="8%">ID</th><th width="24%">' . esc_html__('名称', 'aksrt') . '</th>'
. '<th width="38%">' . esc_html__('说明', 'aksrt') . '</th>'
. '<th width="20%">' . esc_html__('受理管理员', 'aksrt') . '</th><th width="10%"></th></tr></thead><tbody>';
foreach ($types as $row) {
$edit_url = add_query_arg(['type_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']));
$cells = sprintf(
'<tr><td>%d</td><td>%s</td><td>%s</td><td>%s</td><td>',
(int) $row['id'],
esc_html((string) $row['name']),
esc_html((string) $row['description'] !== '' ? (string) $row['description'] : '—'),
esc_html($row['admin_ids'] === [] ? '—' : implode(', ', $row['admin_ids']))
);
$cells .= '<a href="' . esc_url($edit_url) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
$cells .= $this->ticket_delete_button('type_delete', 'type_id', (int) $row['id'], __('删除', 'aksrt'));
$cells .= '</td></tr>';
echo $cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</tbody></table>';
echo '<h4>' . esc_html($editing === null ? __('新增部门', 'aksrt') : __('编辑部门', 'aksrt')) . '</h4>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
echo '<input type="hidden" name="ticket_action" value="type" />';
echo '<input type="hidden" name="ticket_tab" value="config" />';
echo '<input type="hidden" name="type_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
echo wp_nonce_field('aksrt_ticket_type_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
echo '<p><input type="text" name="name" value="' . esc_attr((string) ($editing['name'] ?? '')) . '" placeholder="' . esc_attr__('部门名称', 'aksrt') . '" /></p>';
echo '<p><input type="text" name="description" class="large-text" value="' . esc_attr((string) ($editing['description'] ?? '')) . '" placeholder="' . esc_attr__('部门说明(可选)', 'aksrt') . '" /></p>';
echo '<p><input type="text" name="admin_ids" class="large-text" value="' . esc_attr(implode(',', (array) ($editing['admin_ids'] ?? []))) . '" placeholder="' . esc_attr__('受理管理员用户 ID,逗号分隔(可选)', 'aksrt') . '" /></p>';
submit_button(__('保存部门', 'aksrt'), 'primary', '', false);
echo '</form>';
}
/** 自定义状态:默认状态拒绝改删。 */
private function render_ticket_statuses_panel(): void {
$statuses = \AKSRT\Ticket\TicketStatusService::all();
$edit_id = isset($_GET['status_id']) ? absint($_GET['status_id']) : 0;
$editing = null;
foreach ($statuses as $row) {
if ((int) $row['id'] === $edit_id) {
$editing = $row;
break;
}
}
echo '<h3>' . esc_html__('工单状态', 'aksrt') . '</h3>';
echo '<p class="description">' . esc_html__('默认状态不可修改或删除;删除非默认状态前需先清空该状态下的工单。', 'aksrt') . '</p>';
echo '<table class="widefat fixed striped posts" style="max-width:760px;">';
echo '<thead><tr><th width="8%">ID</th><th width="22%">' . esc_html__('名称', 'aksrt') . '</th>'
. '<th width="18%">' . esc_html__('颜色', 'aksrt') . '</th>'
. '<th width="16%">' . esc_html__('完结', 'aksrt') . '</th>'
. '<th width="16%">' . esc_html__('默认', 'aksrt') . '</th><th width="20%"></th></tr></thead><tbody>';
foreach ($statuses as $row) {
$status_cells = sprintf(
'<tr><td>%d</td><td>%s</td>'
. '<td><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%s">%s</span></td>'
. '<td>%s</td><td>%s</td><td>',
(int) $row['id'],
esc_html((string) $row['name']),
esc_attr((string) $row['color']),
esc_html((string) $row['color']),
esc_html(!empty($row['is_closed']) ? __('是', 'aksrt') : __('否', 'aksrt')),
esc_html(!empty($row['is_default']) ? __('是', 'aksrt') : __('否', 'aksrt'))
);
if (empty($row['is_default'])) {
$status_cells .= '<a href="' . esc_url(add_query_arg(['status_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']))) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
$status_cells .= $this->ticket_delete_button('status_delete', 'status_id', (int) $row['id'], __('删除', 'aksrt'));
} else {
$status_cells .= '—';
}
$status_cells .= '</td></tr>';
echo $status_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</tbody></table>';
echo '<h4>' . esc_html($editing === null ? __('新增状态', 'aksrt') : __('编辑状态', 'aksrt')) . '</h4>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
echo '<input type="hidden" name="ticket_action" value="status_def" />';
echo '<input type="hidden" name="ticket_tab" value="config" />';
echo '<input type="hidden" name="status_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
echo wp_nonce_field('aksrt_ticket_status_def_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
echo '<p><input type="text" name="name" value="' . esc_attr((string) ($editing['name'] ?? '')) . '" placeholder="' . esc_attr__('状态名称', 'aksrt') . '" /> ';
echo '<input type="text" name="color" value="' . esc_attr((string) ($editing['color'] ?? '#6b7280')) . '" placeholder="#6b7280" style="width:120px;" /> ';
echo '<label><input type="checkbox" name="is_closed" value="1"' . checked(!empty($editing['is_closed']), true, false) . ' /> ' . esc_html__('完结状态', 'aksrt') . '</label> ';
submit_button(__('保存状态', 'aksrt'), 'primary', '', false);
echo '</p></form>';
}
/** 预设回复:表格 + 行内编辑 + 新建表单。 */
private function render_ticket_prereplies_panel(): void {
$rows = \AKSRT\Ticket\TicketPrereplyService::all();
$edit_id = isset($_GET['prereply_id']) ? absint($_GET['prereply_id']) : 0;
$editing = null;
foreach ($rows as $row) {
if ((int) $row['id'] === $edit_id) {
$editing = $row;
break;
}
}
echo '<h3>' . esc_html__('预设回复', 'aksrt') . '</h3>';
echo '<table class="widefat fixed striped posts" style="max-width:900px;">';
echo '<thead><tr><th width="8%">ID</th><th width="72%">' . esc_html__('内容', 'aksrt') . '</th><th width="20%"></th></tr></thead><tbody>';
if ($rows === []) {
echo '<tr><td colspan="3">' . esc_html__('暂无预设回复', 'aksrt') . '</td></tr>';
}
foreach ($rows as $row) {
$reply_cells = sprintf(
'<tr><td>%d</td><td>%s</td><td>',
(int) $row['id'],
esc_html((string) $row['content'])
);
$reply_cells .= '<a href="' . esc_url(add_query_arg(['prereply_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']))) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
$reply_cells .= $this->ticket_delete_button('prereply_delete', 'prereply_id', (int) $row['id'], __('删除', 'aksrt'));
$reply_cells .= '</td></tr>';
echo $reply_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</tbody></table>';
echo '<h4>' . esc_html($editing === null ? __('新增预设回复', 'aksrt') : __('编辑预设回复', 'aksrt')) . '</h4>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
echo '<input type="hidden" name="ticket_action" value="prereply" />';
echo '<input type="hidden" name="ticket_tab" value="config" />';
echo '<input type="hidden" name="prereply_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
echo wp_nonce_field('aksrt_ticket_prereply_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
echo '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('预设回复内容', 'aksrt') . '">' . esc_textarea((string) ($editing['content'] ?? '')) . '</textarea></p>';
submit_button(__('保存预设回复', 'aksrt'), 'primary', '', false);
echo '</form>';
}
}
@@ -205,7 +205,6 @@ trait UsersMenusTicketsList {
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="reply" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false);
$html .= '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('填写回复内容', 'aksrt') . '"></textarea></p>';
$html .= '<p><button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button></p>';
@@ -217,7 +216,6 @@ trait UsersMenusTicketsList {
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="status" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false);
$html .= '<p><label>' . esc_html__('状态', 'aksrt') . ' <select name="status">';
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
@@ -259,7 +257,6 @@ trait UsersMenusTicketsList {
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="note" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false);
$html .= '<p><input type="text" name="content" class="large-text" placeholder="' . esc_attr__('添加内部备注(用户不可见)', 'aksrt') . '" /> '
. '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button></p>';
@@ -144,7 +144,8 @@ test('order detail page drops tabs; ticket center replaces the after-sale submen
assert.ok(usersMenus.includes('render_tickets_page'), 'ticket admin page entry registered')
const ticketTrait = read('src/Admin/Native/UsersMenusTicketsCenter.php')
assert.ok(ticketTrait.includes('use UsersMenusTicketsList;'), 'ticket list renderer trait composed')
assert.ok(ticketTrait.includes('use UsersMenusTicketsConfig;'), 'ticket config renderer trait composed')
// 部门 / 状态 / 预设回复已迁至 SPA「工单中心」,原生页只留列表。
assert.ok(!ticketTrait.includes('use UsersMenusTicketsConfig;'), 'config tab must not be composed any more')
const domain = read('src/Core/DomainModel.php')
for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`))
assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/)
@@ -64,14 +64,16 @@ test('closing notifies the counterparty exactly once', () => {
test('default status cannot be edited or deleted in the admin surface', () => {
// REST 侧的状态 CRUD 在 TicketConfigRest trait 里,由 TicketRestAdmin 组合。
// 配置入口已从原生后台迁至 SPA「工单中心」,兜底只在这一处。
const config = read('src/Ticket/TicketConfigRest.php');
assert.equal((config.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
'both update and delete must honour is_default');
assert.match(config, /'aksrt_ticket_status_default'/);
// 原生后台页同样要挡。
// 原生后台不再维护状态定义,因此不再出现 is_default 判断。
const native = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.equal((native.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
'native admin must honour is_default in both paths');
assert.ok(!native.includes("!empty(\$row['is_default'])"),
'status definition guards belong to TicketConfigRest only');
// 删除前还要确认没有工单挂在该状态/部门下(守卫在 TicketConfigRest trait 中)。
assert.match(config, /count_by_status\(\(string\) \$row\['status_key'\]\) > 0/);
@@ -4,9 +4,10 @@
* 工单后台(users.php → aksrt_tickets)契约:
* 1) 子菜单已注册,trait 已组合,写操作走 admin_post 且每个都带 nonce;
* 2) 分页用共享 AdminListPagination,不写死 limit;
* 3) 默认状态与「仍有工单」两类删除被拒;
* 3) 默认状态与「仍有工单」两类删除被拒(兜底在 TicketConfigRest,SPA 工单中心消费);
* 4) SPA 深链 redirect 指向 users.php?page=aksrt_tickets;
* 5) 前端 JS 与 PHP 的 data-* 挂载点对齐。
* 部门 / 状态 / 预设回复的配置已迁至 SPA「工单中心」,本页只剩工单列表。
*/
const assert = require('node:assert/strict');
@@ -36,14 +37,48 @@ test('write actions are nonce-protected and dispatched by action name', () => {
assert.match(center, /check_admin_referer\('aksrt_ticket_' \. \$action \. '_' \. \$id\);/);
assert.ok(center.includes('current_user_can(self::CAPABILITY)'), 'capability check missing');
// 每个动作分支都要有对应处理器。
for (const action of ['status', 'reply', 'type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete', 'note', 'create']) {
// 部门 / 状态 / 预设回复已迁至 SPA 工单中心,本页只剩工单自身的四个动作。
for (const action of ['status', 'reply', 'note', 'create']) {
assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`);
}
// 表单里 nonce 以 action + id 为单位,删除类操作独立小表单 + 二次确认。
assert.match(center, /wp_nonce_field\('aksrt_ticket_' \. \$action \. '_' \. \$id, '_wpnonce', true, false\)/);
assert.match(center, /onsubmit="return confirm\(/);
// nonce 以 action + id 为单位,行内表单各自带自己的 nonce(渲染在 List trait 中)。
// 删除类操作已迁至 SPA(n-dialog 二次确认),本页不再有 onsubmit=confirm 的小表单。
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
for (const hook of [
'aksrt_ticket_reply_',
'aksrt_ticket_status_',
'aksrt_ticket_note_',
]) {
assert.ok(list.includes(hook), `nonce prefix ${hook} missing`);
}
// Tab 已移除,三个行内表单不再输出 ticket_tab 隐藏域。
assert.ok(!list.includes('ticket_tab'), 'ticket_tab hidden input must be gone');
assert.ok(!center.includes('onsubmit="return confirm('), 'config delete forms must be gone');
});
test('the native page carries no config tab and deep-links the SPA instead', () => {
const center = read('src/Admin/Native/UsersMenusTicketsCenter.php');
// Tab 结构已移除:不再有 nav-tab-wrapper,也不读 ?tab=。
assert.ok(!center.includes('nav-tab-wrapper'), 'tab wrapper must be gone');
assert.ok(!center.includes("'config'"), 'config tab must be gone');
assert.ok(!/\$_GET\['tab'\]/.test(center), 'tab param must not be read');
// 配置 Tab 的渲染 trait 与删除按钮 helper 一并移除。
assert.ok(!center.includes('UsersMenusTicketsConfig'), 'config trait must not be composed');
assert.ok(!center.includes('render_ticket_config_tab'), 'config renderer must be gone');
assert.ok(!center.includes('ticket_delete_button'), 'config-only delete helper must be gone');
// 六类配置写操作全部迁至 TicketConfigRest,本页不再分发。
for (const action of ['type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete']) {
assert.ok(!center.includes(`case '${action}':`), `config action ${action} must be gone`);
}
// 页面顶部提供跳往 SPA「工单中心」的快捷入口。
assert.match(center, /admin_url\('admin\.php\?page=aksrt-settings#\/users\/ticket'\)/);
assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTicketsConfig.php')),
'the config trait file must be deleted');
});
test('the ticket list paginates through the shared helper', () => {
@@ -61,24 +96,6 @@ test('the ticket list paginates through the shared helper', () => {
assert.match(list, /'limit' => self::TICKET_PER_PAGE,/);
});
test('config tab edits departments, statuses and prereplies', () => {
const config = read('src/Admin/Native/UsersMenusTicketsConfig.php');
assert.match(config, /render_ticket_types_panel/);
assert.match(config, /render_ticket_statuses_panel/);
assert.match(config, /render_ticket_prereplies_panel/);
// 默认状态行不给编辑/删除入口。
assert.match(config, /empty\(\$row\['is_default'\]\)/);
// 删除按钮带 confirm,且走独立小表单。
assert.match(config, /ticket_delete_button\('status_delete', 'status_id'/);
assert.match(config, /ticket_delete_button\('type_delete', 'type_id'/);
assert.match(config, /ticket_delete_button\('prereply_delete', 'prereply_id'/);
// 状态徽章颜色来自状态表,不写死。
assert.match(config, /--aksrt-ticket-color:%s/);
});
test('detail panel renders replies, quote blocks and internal notes', () => {
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
const body = list.slice(list.indexOf('private function render_ticket_detail_panel'));
@@ -3,7 +3,7 @@
/**
* 「工单中心」配置契约:
* 1) PHP SchemaUsersTicket 分区与 SPA JS schema 的键、顺序、类型、默认值 1:1 对齐;
* 2) SPA 页面已注册(/users/ticket 可达),且挂的是 schemaPage 而非自定义页;
* 2) SPA 页面已注册(/users/ticket 可达),承载 schema 表单 + 三块字典 CRUD;
* 3) 运行时 TicketSettings 缺键回退 schema 默认,数值边界被夹取;
* 4) 前台入口三处均已注册:Center.php 面板定义 + sidebar-nav.php 侧栏项 +
* aksrt-user-center.php 的 built_in_tabs 与 view map。
@@ -67,7 +67,7 @@ test('ticket schema keys match between PHP and SPA JS', () => {
'JS notice text must declare sanitize_type html');
});
test('ticket schema is registered as a schema page under users', () => {
test('ticket schema is registered under users', () => {
const js = read('assets/admin/src/schema/sections/users/ticket.js');
assert.equal(js.includes("key: 'users.ticket'"), true, 'schema key must be users.ticket');
@@ -0,0 +1,106 @@
'use strict';
/**
* SPA「工单中心」接线契约:
* 1) /users/ticket 是 customPage,组件 UsersTicket 已注册并能被 router 解析;
* 2) 页面内嵌 SchemaPage 承载 users/ticket 分区,schema 表单零改动复用;
* 3) 三块字典 CRUD 走 TicketRestAdmin(aksrt/v1/admin)的封装模块;
* 4) 双向深链:SPA → 原生工单列表,原生 → SPA 工单中心;
* 5) 配置 Tab 的残留(tab=config / UsersMenusTicketsConfig)不得回流。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('the ticket page is a custom page backed by UsersTicket', () => {
const loaders = read('assets/admin/src/router/component-loaders/users.js');
assert.ok(
loaders.includes("UsersTicket: () => import('@/pages/users/UsersTicket.vue')"),
'UsersTicket loader missing'
);
const constants = read('assets/admin/src/constants/sections/users.js');
// 入口必须是 customPage 且显式指定组件,否则 router 会回落到 SchemaPage。
assert.match(constants, /customPage\(\{[\s\S]*?component: 'UsersTicket'/);
assert.ok(constants.includes("key: 'users-ticket'"), 'menu page key missing');
assert.ok(constants.includes("route: '/users/ticket'"), 'menu route missing');
assert.ok(constants.includes("section: 'ticket'"), 'menu section missing');
assert.ok(constants.includes("canonicalDomain: 'users'"), 'menu canonical domain missing');
});
test('UsersTicket embeds the schema page and the three CRUD panels', () => {
const page = read('assets/admin/src/pages/users/UsersTicket.vue');
// 基础配置复用 SchemaPage,域/分区显式传入,避免依赖路由 meta。
assert.match(page, /<SchemaPage\s+domain="users"\s+section="ticket"/);
for (const panel of ['TicketTypePanel', 'TicketStatusPanel', 'TicketPrereplyPanel']) {
assert.ok(page.includes(panel), `panel ${panel} not mounted`);
assert.ok(
fs.existsSync(path.join(root, `assets/admin/src/pages/users/partials/ticket/${panel}.vue`)),
`panel ${panel} file missing`
);
}
// 反向深链:回到原生工单列表。
assert.match(page, /users\.php\?page=aksrt_tickets/);
assert.match(page, /window\.aksrtAdminData\?\.adminUrl/);
});
test('ticket admin REST is wrapped in its own api module and registered', () => {
const module = read('assets/admin/src/api/modules/tickets.js');
// TicketRestAdmin 挂在 aksrt/v1/admin,客户端 baseUrl 只到 aksrt/v1,
// 因此必须走 /aksrt/ 全路径形式。
assert.match(module, /'\/aksrt\/v1\/admin\/tickets'/);
for (const method of [
'listTicketStatuses', 'createTicketStatus', 'updateTicketStatus', 'deleteTicketStatus',
'listTicketTypes', 'createTicketType', 'updateTicketType', 'deleteTicketType',
'listTicketPrereplies', 'createTicketPrereply', 'updateTicketPrereply',
'deleteTicketPrereply', 'reorderTicketPrereplies',
]) {
assert.ok(module.includes(`${method}(`), `api method ${method} missing`);
}
const index = read('assets/admin/src/api/modules/index.js');
assert.ok(index.includes("import { ticketsApiMethods } from './tickets.js'"), 'module not imported');
assert.ok(index.includes('...ticketsApiMethods,'), 'module not spread into apiMethods');
});
test('panels enforce the backend guards instead of trusting the client', () => {
const status = read('assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue');
// 默认状态的编辑/删除入口在前端禁用,但后端 TicketConfigRest 仍有 409 兜底。
assert.match(status, /disabled: row\.is_default/);
// 颜色必须落在 TicketStatusService::sanitize_color() 接受的范围内。
assert.match(status, /\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$/i);
assert.match(status, /#6b7280/);
const prereply = read('assets/admin/src/pages/users/partials/ticket/TicketPrereplyPanel.vue');
// 排序接口要求全量 ID。
assert.match(prereply, /rows\.value\.map\(\(row\) => row\.id\)/);
assert.match(prereply, /reorderTicketPrereplies\(ids\)/);
});
test('no config tab residue survives in the SPA or the native page', () => {
const router = read('assets/admin/src/router/index.js');
const constants = read('assets/admin/src/constants/sections/users.js');
const schema = read('assets/admin/src/schema/sections/users/ticket.js');
for (const [file, source] of [['router', router], ['constants', constants], ['ticket schema', schema]]) {
assert.ok(!source.includes('tab=config'), `${file} still references tab=config`);
}
// 原生页不再有配置 Tab 的渲染 trait。
assert.ok(
!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTicketsConfig.php')),
'native config trait must be deleted'
);
});
+1
View File
@@ -40,6 +40,7 @@ These use dedicated Vue pages + dedicated REST modules:
- Footer: `FooterSettings` + footer settings API
- Permissions capabilities matrix: `PermissionsCapabilities` + `/permissions/capabilities`
- Shop products/orders and other operational tables
- 工单中心: `UsersTicket` + schema `users/ticket` + `aksrt/v1/admin/tickets*`(部门 / 状态 / 预设回复)
## Deploy checklist
+31 -30
View File
@@ -1,5 +1,5 @@
{
"generated_at": "2026-10-25T15:29:20+00:00",
"generated_at": "2026-10-26T11:36:52+00:00",
"errors": [],
"warnings": [
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",
@@ -64,7 +64,7 @@
"info": [
"PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ExtensionsSecurity, ExtensionsSystem, ToolsBackup, ToolsHealth, ToolsImportExport, PostListList",
"Menu pages discovered: 61",
"admin build ok; chunks=30"
"admin build ok; chunks=31"
],
"domains": {
"global": {
@@ -260,36 +260,37 @@
"entry": true,
"missing_chunks": [],
"chunks": [
"SchemaPage.DCJ4QdH8.js",
"vendor-vue.BqIbVHit.js",
"FaIcon.BMA0TNWc.js",
"AksMediaField.C9RRDn2V.js",
"FaIconPicker.CA3w9r30.js",
"shared-api.Cak1GAri.js",
"vendor-misc.Dr7TL_YE.js",
"vendor-draggable.CjYjXbZD.js",
"SchemaPage.C79jkx8B.js",
"vendor-vue.AXlcobq-.js",
"FaIcon.Cta3HYOc.js",
"AksMediaField.CiF8KSwp.js",
"FaIconPicker.DJI1XtMq.js",
"shared-api.GLIRMK1p.js",
"vendor-misc.DSSPvhm2.js",
"vendor-draggable.CsbWt1CM.js",
"level.E3ANSDAX.js",
"badge.CKTsmH1z.js",
"vendor-naive.Dzr6v856.js",
"DeferredPage.Bo6-Z82H.js",
"NativeOpsRedirect.wMMcdVjx.js",
"FloatingButtonsSettings.DM1NXEcr.js",
"PageHeader.UhySXCpq.js",
"FooterTabbarSettings.8D3P8zNv.js",
"PostPageSettings.DfkMmr7s.js",
"CommentSettings.ke9PbifA.js",
"PermissionsCapabilities.CnQfjtxC.js",
"CmsCredits.Dh2b9uHb.js",
"ThemeInfoPage.zYktV7qz.js",
"ThemeAuthorizationPage.e-ksjN6X.js",
"DocsPage.Dtd_-3fP.js",
"UsersVerify.CAJUfACd.js",
"UsersLevel.CBoqr3ux.js",
"UsersBadge.B_lwRfdl.js",
"UsersWechatTemplate.F0aCC5C-.js",
"ToolsImportExport.CN30A6Ge.js",
"ToolsHealth.B0GkAdJR.js",
"ToolsUpdate.DHtpNLfB.js"
"vendor-naive.DgdCsaiA.js",
"DeferredPage.CDpa6_lq.js",
"NativeOpsRedirect.CD8j4uaH.js",
"FloatingButtonsSettings.BB4Ds-Vh.js",
"PageHeader.C1t645NL.js",
"FooterTabbarSettings.D7iCVwp2.js",
"PostPageSettings.Cnn7DRgi.js",
"CommentSettings.1g5G6LQL.js",
"PermissionsCapabilities.eEUosHCJ.js",
"CmsCredits.DUise9Hl.js",
"ThemeInfoPage.DuqoCync.js",
"ThemeAuthorizationPage.BDGht6JA.js",
"DocsPage.CBhfO0JX.js",
"UsersVerify.CWq6m0ZY.js",
"UsersLevel.BnvrTSj0.js",
"UsersBadge.CZSYaoC4.js",
"UsersWechatTemplate.BGKwGvbY.js",
"UsersTicket.DmhmPeKC.js",
"ToolsImportExport.CuVI1mrN.js",
"ToolsHealth.BT7gJFVm.js",
"ToolsUpdate.59iH8s4m.js"
]
}
}