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

@@ -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>