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