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