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:
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>
|
||||
Reference in new issue
Block a user