feat(device-center): 帮助中心内容管理落地 + 适用范围通用/指定设备切换

- 新增设备中心 / 帮助中心 / 内容管理菜单与页面(列表 + 新增/编辑弹框 + 详情抽屉)
- 适用范围改为 Radio:通用(默认) / 指定设备;指定设备时才出现设备分类多选
- 列表通用记录显示「通用」Tag,指定设备记录显示设备分类文字
- 详情抽屉按 scope 分支展示;mock 数据补充 scope 字段并演示两种形态

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fengpu
2026-07-17 14:45:58 +08:00
co-authored by Claude Opus 4.7
parent 0e884b884a
commit e28baced32
14 changed files with 1121 additions and 0 deletions
@@ -0,0 +1,220 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 帮助中心 - 内容管理 mock 数据
* - 维护系统以及设备的操作说明、常见问题等帮助内容
*/
type HelpCategory = '操作说明' | '常见问题' | '新手指引' | '系统版本' | '其他'
type ApplicableScope = 'general' | 'specified'
interface HelpContentRecord {
id: string
code: string
category: HelpCategory
title: string
clientTag: string
scope: ApplicableScope
deviceTypes: string[]
content: string
createTime: string
}
interface HelpContentParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
category?: HelpCategory
[key: string]: unknown
}
const getParams = (params: RequestParameter): HelpContentParams => (params ?? {}) as HelpContentParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const paginate = <T extends HelpContentRecord>(list: T[], params: HelpContentParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const helpContentList: HelpContentRecord[] = [
{
id: 'hlp-001',
code: 'HLP001',
category: '操作说明',
title: '智能货柜操作说明',
clientTag: 'APP-001',
scope: 'specified',
deviceTypes: ['智能货柜', '档口机'],
content: '智能货柜操作说明:\n1. 开机前检查电源连接是否正常;\n2. 按照屏幕提示完成初始化配置;\n3. 放置菜品后确认称重数据;\n4. 定期清理货柜内部卫生。',
createTime: '2026-05-10 09:30:00',
},
{
id: 'hlp-002',
code: 'HLP002',
category: '常见问题',
title: '设备离线如何处理',
clientTag: 'APP-003',
scope: 'specified',
deviceTypes: ['智能货柜'],
content: '设备离线常见问题及处理方法:\n1. 检查设备网络连接是否正常;\n2. 确认路由器工作状态;\n3. 重启设备后观察是否恢复在线;\n4. 如仍离线,请联系运维人员上门处理。',
createTime: '2026-05-12 14:20:00',
},
{
id: 'hlp-003',
code: 'HLP003',
category: '系统版本',
title: '系统升级通知(2026年7月)',
clientTag: 'APP-001',
scope: 'general',
deviceTypes: [],
content: '各位用户:\n系统将于2026年7月20日凌晨2:00-6:00进行例行升级维护,期间部分服务可能短暂中断,请提前做好业务安排。\n升级内容:\n1. 优化设备心跳检测机制;\n2. 修复数据分发异常问题;\n3. 提升系统整体稳定性。',
createTime: '2026-05-15 10:00:00',
},
{
id: 'hlp-004',
code: 'HLP004',
category: '操作说明',
title: '入库秤使用指南',
clientTag: 'APP-003',
scope: 'specified',
deviceTypes: ['入库秤'],
content: '入库秤使用指南:\n1. 确认秤面平稳放置;\n2. 开机后等待归零提示;\n3. 将待称物品轻放秤面中央;\n4. 待读数稳定后记录数据;\n5. 称量完毕后清理秤面。',
createTime: '2026-05-18 16:45:00',
},
{
id: 'hlp-005',
code: 'HLP005',
category: '常见问题',
title: '客户端登录异常通用排查',
clientTag: 'APP-001',
scope: 'general',
deviceTypes: [],
content: '客户端登录异常通用排查步骤:\n1. 确认账号密码输入是否正确;\n2. 检查客户端版本是否需要升级;\n3. 切换网络环境(WiFi/4G)后重试;\n4. 清理客户端本地缓存;\n5. 如仍无法登录,请联系管理员重置账号。',
createTime: '2026-05-20 11:10:00',
},
{
id: 'hlp-006',
code: 'HLP006',
category: '其他',
title: '炒菜机器人维护保养注意事项',
clientTag: 'APP-003',
scope: 'specified',
deviceTypes: ['炒菜机器人'],
content: '炒菜机器人维护保养注意事项:\n1. 每日使用后清洁锅体表面油污;\n2. 每周检查搅拌臂转动是否顺畅;\n3. 每月对传动部件进行润滑保养;\n4. 定期检查电源线路绝缘状况;\n5. 发现异常噪音或异味立即停机报修。',
createTime: '2026-05-25 08:20:00',
},
{
id: 'hlp-007',
code: 'HLP007',
category: '新手指引',
title: '营养秤校准流程',
clientTag: 'APP-001',
scope: 'specified',
deviceTypes: ['营养秤'],
content: '营养秤校准流程:\n1. 确认秤面无物品且干净;\n2. 进入设备设置菜单;\n3. 选择"校准"功能;\n4. 按照提示放置标准砝码;\n5. 等待校准完成提示;\n6. 校准完成后进行验证称量。',
createTime: '2026-06-01 13:50:00',
},
{
id: 'hlp-008',
code: 'HLP008',
category: '常见问题',
title: '统计大屏数据显示异常',
clientTag: 'APP-001',
scope: 'specified',
deviceTypes: ['统计大屏'],
content: '统计大屏数据显示异常排查:\n1. 检查大屏与数据服务器的网络连接;\n2. 确认数据分发配置是否正确;\n3. 查看大屏本地缓存是否需要刷新;\n4. 重启大屏设备;\n5. 联系技术支持人员协助排查。',
createTime: '2026-06-05 15:30:00',
},
]
/** 关键词搜索:编号 / 标题 */
const matchKeyword = (item: HelpContentRecord, keyword?: string): boolean => {
if (!keyword) return true
return includes(item.code, keyword) || includes(item.title, keyword)
}
const listHelpContent = (ctx: MockContext): ApiResponse<HelpContentRecord[]> => {
const params = getParams(ctx.params)
const filtered = helpContentList.filter((item) => {
if (!matchKeyword(item, params.keyword)) return false
if (params.category && item.category !== params.category) return false
return true
})
if (params.column === 'createTime' && params.order) {
filtered.sort((a, b) => {
const cmp = a.createTime.localeCompare(b.createTime)
return params.order === 'ascend' ? cmp : -cmp
})
}
return paginate(filtered, params)
}
const infoHelpContent = (ctx: MockContext): ApiResponse<HelpContentRecord | null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = helpContentList.find((item) => item.id === id)
return ok('查询成功', target ?? null)
}
const addHelpContent = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const now = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
const createTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
const record: HelpContentRecord = {
id: `hlp-${Date.now().toString(36)}`,
code: String(params.code ?? ''),
category: (params.category as HelpCategory) ?? '其他',
title: String(params.title ?? ''),
clientTag: String(params.clientTag ?? ''),
scope: (params.scope as ApplicableScope) ?? 'general',
deviceTypes: Array.isArray(params.deviceTypes) ? (params.deviceTypes as string[]) : [],
content: String(params.content ?? ''),
createTime,
}
helpContentList.unshift(record)
return ok('新增成功')
}
const updateHelpContent = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = helpContentList.find((item) => item.id === id)
if (!target) return ok('记录不存在', null)
if (params.code !== undefined) target.code = String(params.code)
if (params.category !== undefined) target.category = params.category as HelpCategory
if (params.title !== undefined) target.title = String(params.title)
if (params.clientTag !== undefined) target.clientTag = String(params.clientTag)
if (params.scope !== undefined) target.scope = (params.scope as ApplicableScope) ?? 'general'
if (params.deviceTypes !== undefined) target.deviceTypes = Array.isArray(params.deviceTypes) ? (params.deviceTypes as string[]) : []
if (params.content !== undefined) target.content = String(params.content)
return ok('更新成功')
}
const deleteHelpContent = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const idx = helpContentList.findIndex((item) => item.id === id)
if (idx >= 0) helpContentList.splice(idx, 1)
return ok('删除成功')
}
registerMocks([
{ method: 'POST', url: '/admin/device/help/content/page', handler: listHelpContent },
{ method: 'POST', url: '/admin/device/help/content/info', handler: infoHelpContent },
{ method: 'POST', url: '/admin/device/help/content/add', handler: addHelpContent },
{ method: 'POST', url: '/admin/device/help/content/update', handler: updateHelpContent },
{ method: 'POST', url: '/admin/device/help/content/delete', handler: deleteHelpContent },
])
+2
View File
@@ -86,5 +86,7 @@ import './miniprogram/ops/workOrder'
import './miniprogram/ops/scan'
// 实施运维小程序 - 巡检(计划列表 4 状态 + 任务详情逐项填报 + 异常转工单)
import './miniprogram/ops/inspect'
// 设备中心 - 帮助中心 / 内容管理(CRUD)
import './device-center/helpContent'
// 实施运维小程序 - 我的(个人统计 + 消息中心 + 离线缓存)
import './miniprogram/ops/me'
+15
View File
@@ -553,6 +553,21 @@ export const adminSections: NavSection[] = [
},
],
},
// 8. 帮助中心
{
key: 'admin-device-help-center',
label: '帮助中心',
icon: 'QuestionCircleOutlined',
children: [
{
key: 'admin-device-help-content',
label: '内容管理',
path: 'help/content',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/help/content/content.vue'),
},
],
},
],
},
]
@@ -0,0 +1,23 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { HelpContentItem, HelpContentPayload, SearchForm } from '../types'
/** 查询帮助内容列表 */
export const list = (params: SearchForm): Promise<ApiResponse<HelpContentItem[]>> =>
postRequest('axiosRequest', '/admin/device/help/content/page', params)
/** 查询帮助内容详情 */
export const info = (id: string): Promise<ApiResponse<HelpContentItem>> =>
postRequest('axiosRequest', '/admin/device/help/content/info', { id })
/** 新增帮助内容 */
export const add = (payload: HelpContentPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/help/content/add', payload)
/** 更新帮助内容 */
export const update = (payload: HelpContentPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/help/content/update', payload)
/** 删除帮助内容 */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/help/content/delete', { id })
@@ -0,0 +1,106 @@
<template>
<a-drawer
v-model:open="visible"
title="内容详情"
:width="600"
:keyboard="false"
:mask-closable="false"
destroy-on-close
>
<a-spin :spinning="spinning">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions-item label="编号">{{ detail.code ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属类别">
<a-tag :color="categoryColorMap[detail.category] ?? 'default'">{{ detail.category }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="标题" :span="2">{{ detail.title ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="客户端标识">{{ detail.clientTag ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detail.createTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="适用范围" :span="2">
<a-space wrap>
<a-tag :color="detail.scope === 'specified' ? 'blue' : 'green'">
{{ detail.scope === 'specified' ? '指定设备' : '通用' }}
</a-tag>
<template v-if="detail.scope === 'specified'">
<a-tag v-for="d in detail.deviceTypes" :key="d" color="blue">{{ d }}</a-tag>
<span v-if="!detail.deviceTypes?.length"></span>
</template>
</a-space>
</a-descriptions-item>
<a-descriptions-item label="内容描述" :span="2">
<div class="detail-content">{{ detail.content ?? '—' }}</div>
</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 帮助内容 - 详情抽屉
* - 只读展示,所有字段 ?? '—'
*/
import { ref } from 'vue'
import { postRequest } from '@axios'
import type { ApiResponse } from '@axios'
import type { ApplicableScope, HelpCategory } from '../../../../types'
/** 帮助内容列表项 */
interface HelpContentItem {
id: string
code: string
category: HelpCategory
title: string
clientTag: string
scope: ApplicableScope
deviceTypes: string[]
content: string
createTime: string
}
/** 类别 → Tag 颜色 */
const categoryColorMap: Record<string, string> = {
'操作说明': 'blue',
'常见问题': 'orange',
'新手指引': 'green',
'系统版本': 'purple',
'其他': 'default',
}
const visible = ref(false)
const spinning = ref(false)
const detail = ref<HelpContentItem | null>(null)
/** 打开详情抽屉 */
const openModal = (record: HelpContentItem): void => {
visible.value = true
spinning.value = true
postRequest('axiosRequest', '/admin/device/help/content/info', { id: record.id })
.then((res: ApiResponse<HelpContentItem>) => {
if (res.code === '00000') {
detail.value = res.data ?? null
}
})
.catch((err: unknown) => {
console.error('查询帮助内容详情失败:', err)
})
.finally(() => {
spinning.value = false
})
}
defineExpose({ openModal })
</script>
<style scoped lang="less">
.detail-content {
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
}
</style>
@@ -0,0 +1,138 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '110px', minWidth: '110px' } }"
>
<a-row :gutter="16">
<a-col v-if="pageInfo.type === 'edit'" :span="12">
<a-form-item label="编号" name="code">
<a-input
v-model:value="form.code"
v-only-alphanumeric
disabled
allow-clear
:maxlength="40"
placeholder="系统自动生成"
/>
</a-form-item>
</a-col>
<a-col :span="pageInfo.type === 'edit' ? 12 : 24">
<a-form-item label="所属类别" name="category">
<a-select v-model:value="form.category" :options="CATEGORY_OPTIONS" placeholder="请选择" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="标题" name="title">
<a-input
v-model:value="form.title"
v-no-space
allow-clear
:maxlength="100"
placeholder="请输入标题"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="客户端标识" name="clientTag">
<a-input
v-model:value="form.clientTag"
allow-clear
:maxlength="40"
placeholder="如 APP-001"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="适用范围" name="scope">
<a-radio-group v-model:value="form.scope">
<a-radio
v-for="opt in SCOPE_OPTIONS"
:key="opt.value"
:value="opt.value"
>{{ opt.label }}</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-form-item
v-if="form.scope === 'specified'"
label="适用设备分类"
name="deviceTypes"
>
<a-select
v-model:value="form.deviceTypes"
:options="DEVICE_TYPE_OPTIONS"
mode="multiple"
show-search
allow-clear
placeholder="请选择适用设备分类"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="内容描述" name="content">
<a-textarea
v-model:value="form.content"
:rows="8"
:maxlength="2000"
show-count
placeholder="请输入内容描述(富文本编辑器,原型以 textarea 模拟)"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 帮助内容 - 新增/编辑弹框
* - 字段:编号 / 所属类别 / 标题 / 客户端标识 / 适用范围 / 适用设备分类(指定设备时) / 内容描述
*/
import { usePage } from './init/usePage'
import {
CATEGORY_OPTIONS,
DEVICE_TYPE_OPTIONS,
SCOPE_OPTIONS,
} from '@pages/admin-portal/device-center/help/content/types'
const emit = defineEmits<{
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
rules,
openModal,
closeModal,
submit,
} = usePage(emit)
defineExpose({ openModal })
</script>
@@ -0,0 +1,20 @@
/**
* 帮助内容 - 新增/编辑弹框 - 接口层
* - 复用主页的 info / add / update
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { Form } from '../types'
/** 详情查询(编辑回填) */
export const info = (id: string): Promise<ApiResponse<Form>> =>
postRequest('axiosRequest', '/admin/device/help/content/info', { id })
/** 新增 */
export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/help/content/add', params)
/** 编辑 */
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/help/content/update', params)
@@ -0,0 +1,166 @@
/**
* 帮助内容 - 新增/编辑弹框 - 主逻辑
*
* 严格遵循 modal-spec.md
* - createInitForm 工厂函数
* - openModal 用 Promise.all 拉取详情
* - addRequest 必须 delete params.id
* - submit 开头立即 pageInfo.spin = true
* - 用 params.id 判断新增/编辑
*/
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { add, edit, info } from '../api'
import type { Form, ModalType, OpenContext, PageInfo } from '../types'
/** 初始表单工厂:适用范围默认通用 */
const createInitForm = (): Form => ({
id: undefined,
code: '',
category: '操作说明',
title: '',
clientTag: '',
scope: 'general',
deviceTypes: [],
content: '',
})
export const usePage = (emit: (e: 'load') => void) => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
type: 'add',
title: '',
width: 720,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
/** 适用范围切换回通用时清空已选设备分类 */
watch(
() => form.value.scope,
(next) => {
if (next === 'general') form.value.deviceTypes = []
},
)
/** 指定设备时校验至少选择一个设备分类 */
const validateDeviceTypes = async (): Promise<void> => {
if (form.value.scope === 'specified' && (!form.value.deviceTypes || form.value.deviceTypes.length === 0)) {
return Promise.reject(new Error('请选择至少一个设备分类'))
}
return Promise.resolve()
}
const rules: Record<string, Rule[]> = {
category: [{ required: true, message: '请选择所属类别', trigger: 'change' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
clientTag: [{ required: true, message: '请输入客户端标识', trigger: 'blur' }],
scope: [{ required: true, message: '请选择适用范围', trigger: 'change' }],
deviceTypes: [{ required: true, validator: validateDeviceTypes, trigger: 'change' }],
content: [{ required: true, message: '请输入内容描述', trigger: 'blur' }],
}
const resetForm = (): void => {
formRef.value?.clearValidate()
form.value = createInitForm()
}
const closeModal = (): void => {
resetForm()
pageInfo.visible = false
}
const infoRequest = (id: string) => info(id)
/** 打开弹框 */
const openModal = (type: ModalType, ctx?: OpenContext): void => {
pageInfo.visible = true
pageInfo.spin = true
pageInfo.type = type
resetForm()
pageInfo.title = type === 'add' ? '新增帮助内容' : '编辑帮助内容'
const requests: Promise<unknown>[] = []
if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id))
Promise.all(requests)
.then((results) => {
if (type === 'edit' && ctx?.id) {
const infoRes = results[0] as { code: string; data: Form }
if (infoRes?.code === '00000' && infoRes.data) {
form.value = { ...createInitForm(), ...infoRes.data }
}
}
})
.catch((err: unknown) => {
console.error('打开弹框初始化失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const successRequest = (msg: string): void => {
void message.success(msg)
emit('load')
closeModal()
}
const addRequest = (params: Record<string, unknown>): void => {
delete params.id
add(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('新增失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const editRequest = (params: Record<string, unknown>): void => {
edit(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('编辑失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const submit = (): void => {
pageInfo.spin = true
formRef.value
?.validate()
.then(() => {
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
if (params.id) editRequest(params)
else addRequest(params)
})
.catch((err: unknown) => {
pageInfo.spin = false
console.error('表单验证失败:', err)
})
}
return {
pageInfo,
formRef,
form,
rules,
openModal,
closeModal,
submit,
}
}
@@ -0,0 +1,39 @@
import type { FormInstance } from 'ant-design-vue/es/form'
import type { ApplicableScope, HelpCategory } from '../../../../types'
/** 弹框类型 */
export type ModalType = 'add' | 'edit'
/** 弹框页面状态 */
export interface PageInfo {
visible: boolean
type: ModalType
title: string
width: number
spin: boolean
}
/** 打开弹框上下文 */
export interface OpenContext {
/** 编辑模式必传 */
id?: string
}
/** 表单字段 */
export interface Form {
id: string | undefined
/** 编号 */
code: string
/** 所属类别 */
category: HelpCategory
/** 标题 */
title: string
/** 客户端标识 */
clientTag: string
/** 适用范围 */
scope: ApplicableScope
/** 适用设备分类(scope='specified' 时使用) */
deviceTypes: string[]
/** 内容描述 */
content: string
}
@@ -0,0 +1,77 @@
<template>
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="编号 / 标题"
style="width: 220px"
/>
</a-form-item>
<a-form-item label="所属类别">
<a-select
v-model:value="search.category"
:options="CATEGORY_OPTIONS"
allow-clear
placeholder="全部类别"
style="width: 160px"
/>
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" @click="openAddModal">
<template #icon><PlusOutlined /></template>
新增内容
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('detail', record)">详情</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
<Detail ref="detailRef" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import Detail from './component/drawer/detail/detail.vue'
import { usePage } from './init/usePage'
import { CATEGORY_OPTIONS } from './types'
const {
pageLoading,
search,
table,
addOrEditRef,
detailRef,
searchQuery,
resetQuery,
dataSourceChange,
openAddModal,
handleAction,
listRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -0,0 +1,147 @@
import { onMounted, reactive, ref } from 'vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import Detail from '../component/drawer/detail/detail.vue'
import { list, remove } from '../api'
import type { HelpContentItem, SearchForm } from '../types'
import { createSearchKey } from './useSearch'
import { setActionClickHandler, tableColumns } from './useTable'
/**
* 帮助中心 - 内容管理页主编排:
* - 标准 CRUD + 详情抽屉
* - 操作列:编辑 / 详情 / 删除
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const search = reactive<SearchForm>(createSearchKey())
const table = reactive<TableState<HelpContentItem>>({
columns: tableColumns,
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
/** 新增/编辑弹框 ref */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
/** 详情弹窗 ref */
const detailRef = ref<InstanceType<typeof Detail> | null>(null)
/** 操作列回调:编辑 / 详情 / 删除 */
const onAction = (type: 'edit' | 'detail' | 'delete', record: HelpContentItem): void => {
if (type === 'edit') {
addOrEditRef.value?.openModal('edit', { id: record.id })
return
}
if (type === 'detail') {
detailRef.value?.openModal(record)
return
}
if (type === 'delete') {
deleteRecord(record)
}
}
// 注入列回调(tableColumns 模块级常量,通过 setter 注入)
setActionClickHandler(onAction)
const listRequest = (): void => {
pageLoading.value = true
list(search)
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = table.dataSource.length
}
})
.catch((err: unknown) => {
console.error('帮助内容列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
const resetQuery = (): void => {
Object.assign(search, createSearchKey())
table.pagination.current = 1
listRequest()
}
const dataSourceChange = (pagination: TablePaginationConfig): void => {
table.pagination.current = pagination.current ?? 1
table.pagination.pageSize = pagination.pageSize ?? 30
}
const rowSelection = {
selectedRowKeys,
onChange: (keys: (string | number)[]): void => {
selectedRowKeys.value = keys
},
}
/** 打开新增弹框 */
const openAddModal = (): void => {
addOrEditRef.value?.openModal('add')
}
/** 删除记录(二次确认) */
const deleteRecord = (record: HelpContentItem): void => {
Modal.confirm({
title: '确认删除?',
content: `帮助内容「${record.title}」删除后不可恢复,请谨慎操作。`,
okType: 'danger',
okText: '确认删除',
cancelText: '取消',
onOk: () => {
remove(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => {
console.error('帮助内容删除失败:', err)
})
},
})
}
onMounted(() => {
listRequest()
})
const handleAction = (type: 'edit' | 'detail' | 'delete', record: HelpContentItem): void => {
onAction(type, record)
}
return {
pageLoading,
search,
table,
selectedRowKeys,
rowSelection,
addOrEditRef,
detailRef,
searchQuery,
resetQuery,
dataSourceChange,
openAddModal,
handleAction,
listRequest,
}
}
@@ -0,0 +1,7 @@
import type { SearchForm } from '../types'
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
category: undefined,
})
@@ -0,0 +1,58 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Button, Tag } from 'ant-design-vue'
import type { ApplicableScope, HelpContentItem, HelpCategory } from '../types'
import { categoryColorMap, scopeColorMap, scopeLabelMap } from '../types'
/** 帮助内容列表列定义(含操作列:编辑 / 详情 / 删除) */
export const tableColumns: TableColumnsType = [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '编号', dataIndex: 'code', align: 'left', width: 120, resizable: true, ellipsis: true },
{
title: '所属类别',
dataIndex: 'category',
align: 'center',
width: 110,
customRender: ({ value }: { value: HelpCategory }) =>
h(Tag, { color: categoryColorMap[value] ?? 'default' }, () => value),
},
{ title: '标题', dataIndex: 'title', align: 'left', width: 240, resizable: true, ellipsis: true },
{ title: '客户端标识', dataIndex: 'clientTag', align: 'center', width: 130, resizable: true, ellipsis: true },
{
title: '适用范围',
dataIndex: 'scope',
align: 'left',
width: 200,
resizable: true,
ellipsis: true,
customRender: ({ record }: { record: HelpContentItem }) => {
const scope: ApplicableScope = record.scope ?? 'general'
if (scope === 'general') {
return h(Tag, { color: scopeColorMap.general }, () => scopeLabelMap.general)
}
return (record.deviceTypes ?? []).join('、') || '—'
},
},
{ title: '创建时间', dataIndex: 'createTime', align: 'center', width: 170, sorter: true, resizable: true },
{
title: '操作',
dataIndex: 'action',
align: 'center',
fixed: 'right',
width: 200,
customRender: ({ record }: { record: HelpContentItem }) =>
h('div', {}, [
h(Button, { type: 'link', size: 'small', onClick: () => onActionClick?.('edit', record) }, () => '编辑'),
h(Button, { type: 'link', size: 'small', onClick: () => onActionClick?.('detail', record) }, () => '详情'),
h(Button, { type: 'link', size: 'small', danger: true, onClick: () => onActionClick?.('delete', record) }, () => '删除'),
]),
},
]
/** 操作点击回调(由 usePage 注入) */
let onActionClick: ((type: 'edit' | 'detail' | 'delete', record: HelpContentItem) => void) | null = null
/** 注入操作点击回调(供 usePage 调用) */
export const setActionClickHandler = (handler: (type: 'edit' | 'detail' | 'delete', record: HelpContentItem) => void): void => {
onActionClick = handler
}
@@ -0,0 +1,103 @@
import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types'
/** 所属类别 */
export type HelpCategory = '操作说明' | '常见问题' | '新手指引' | '系统版本' | '其他'
/** 适用范围:通用 / 指定设备 */
export type ApplicableScope = 'general' | 'specified'
/** 帮助内容列表项 */
export interface HelpContentItem extends BaseRecord {
/** 编号 */
code: string
/** 所属类别 */
category: HelpCategory
/** 标题 */
title: string
/** 客户端标识 */
clientTag: string
/** 适用范围 */
scope: ApplicableScope
/** 适用设备分类(scope='specified' 时生效) */
deviceTypes: string[]
/** 内容描述 */
content: string
/** 创建时间 */
createTime: string
}
/** 搜索表单 */
export interface SearchForm extends BaseSearchForm {
keyword?: string
category?: HelpCategory
}
/** 新增/编辑提交载荷 */
export interface HelpContentPayload {
id?: string
code: string
category: HelpCategory
title: string
clientTag: string
scope: ApplicableScope
deviceTypes: string[]
content: string
}
/** 类别下拉选项 */
export const CATEGORY_OPTIONS = [
{ label: '操作说明', value: '操作说明' },
{ label: '常见问题', value: '常见问题' },
{ label: '新手指引', value: '新手指引' },
{ label: '系统版本', value: '系统版本' },
{ label: '其他', value: '其他' },
]
/** 客户端标识下拉选项 */
export const CLIENT_TAG_OPTIONS = [
{ label: '综合管理后台', value: '综合管理后台' },
{ label: '租户运营后台', value: '租户运营后台' },
{ label: '微信小程序', value: '微信小程序' },
{ label: '硬件终端', value: '硬件终端' },
]
/** 适用范围下拉/Radio 选项 */
export const SCOPE_OPTIONS = [
{ label: '通用', value: 'general' as ApplicableScope },
{ label: '指定设备', value: 'specified' as ApplicableScope },
]
/** 适用范围 → 文本映射 */
export const scopeLabelMap: Record<ApplicableScope, string> = {
general: '通用',
specified: '指定设备',
}
/** 适用范围 → Tag 颜色 */
export const scopeColorMap: Record<ApplicableScope, string> = {
general: 'green',
specified: 'blue',
}
/** 设备类型下拉选项 */
export const DEVICE_TYPE_OPTIONS = [
{ label: '智能货柜', value: '智能货柜' },
{ label: '档口机', value: '档口机' },
{ label: '入库秤', value: '入库秤' },
{ label: '营养秤', value: '营养秤' },
{ label: '吐盘机', value: '吐盘机' },
{ label: '结算台', value: '结算台' },
{ label: '配比秤', value: '配比秤' },
{ label: '带鱼屏', value: '带鱼屏' },
{ label: '统计大屏', value: '统计大屏' },
{ label: '炒菜机器人', value: '炒菜机器人' },
]
/** 类别 → Tag 颜色 */
export const categoryColorMap: Record<HelpCategory, string> = {
'操作说明': 'blue',
'常见问题': 'orange',
'新手指引': 'green',
'系统版本': 'purple',
'其他': 'default',
}