refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。
- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
(高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
.section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
This commit is contained in:
@@ -1,214 +1,121 @@
|
||||
---
|
||||
name: project-crud
|
||||
description: 在本模板项目中创建或修改 CRUD 列表页、表格列、搜索区、表单弹窗。使用 GiPageLayout、useTable、GiButton、a-table、GiForm、useDict 时加载本技能。
|
||||
description: 列表页 + 新增/编辑弹窗的标准写法(Naive UI + 本项目约定)。新增或修改 CRUD 页面时加载本技能。
|
||||
---
|
||||
|
||||
# CRUD 列表页开发
|
||||
# CRUD 列表 / 表单(标准写法)
|
||||
|
||||
> 写/改 `.vue` 前先读 [code-vue](../code-vue/SKILL.md);写样式前读 [code-css](../code-css/SKILL.md)。
|
||||
先找一个同类页面照抄结构(`src/views/tenant/index.vue`、`src/views/user/index.vue`、
|
||||
`src/views/dict/index.vue` 是最典型的三份)。
|
||||
|
||||
## 标准页面结构
|
||||
## 列表页骨架
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<GiPageLayout margin collapsed>
|
||||
<template #left><!-- 可选:树/分类 --></template>
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NCard, NDataTable, NSpace, NTag, useMessage } from 'naive-ui'
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import * as xxxApi from '@/apis/xt/xxx'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import { fmtDate } from '@/utils/format'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
|
||||
<a-row justify="space-between" class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<GiButton type="add" @click="onAdd" />
|
||||
<GiButton type="delete" @click="onBatchDelete" />
|
||||
</a-space>
|
||||
<a-space wrap>
|
||||
<!-- 搜索表单项 -->
|
||||
<GiButton type="search" @click="search" />
|
||||
<GiButton type="reset" @click="reset" />
|
||||
</a-space>
|
||||
</a-row>
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const list = ref<XxxVO[]>([])
|
||||
const query = reactive({ page: 1, pageSize: 20, keyword: '' })
|
||||
const total = ref(0)
|
||||
|
||||
<a-table
|
||||
class="g-table"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:columns="tableColumns"
|
||||
:bordered="{ cell: true }"
|
||||
:scroll="{ x: '100%', y: '100%', minWidth: 1200 }"
|
||||
:pagination="pagination"
|
||||
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
|
||||
:selected-keys="selectedKeys"
|
||||
@select="select"
|
||||
@select-all="selectAll"
|
||||
/>
|
||||
|
||||
<XxxFormModal ref="XxxFormModalRef" @save-success="search" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
defineOptions({ name: 'ModuleName' })
|
||||
</script>
|
||||
```
|
||||
|
||||
## useTable 用法
|
||||
|
||||
```ts
|
||||
import type * as T from '@/apis/system/user'
|
||||
import { baseAPI } from '@/apis/system/user'
|
||||
import { useDict, useTable } from '@/hooks'
|
||||
|
||||
const { dictData } = useDict(['STATUS'] as const)
|
||||
|
||||
const queryParams = reactive({ status: '', username: '' })
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
selectedKeys,
|
||||
search,
|
||||
select,
|
||||
selectAll,
|
||||
fixed, // 操作列 fixed: 'right'(小屏自动取消)
|
||||
onDelete,
|
||||
onBatchDelete,
|
||||
refresh
|
||||
} = useTable({
|
||||
listAPI: (page) => baseAPI.getList({ ...page, ...queryParams }),
|
||||
deleteAPI: (ids) => baseAPI.delete({ ids }),
|
||||
immediate: true,
|
||||
rowKey: 'id', // 默认 id
|
||||
crossPageSelect: false // 跨页多选时设为 true
|
||||
})
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `search()` 会重置到第 1 页并清空选中
|
||||
- `refresh()` 保持当前页刷新
|
||||
- `listAPI` 参数为 `{ page, size }`,需展开到接口
|
||||
- 空字符串查询条件建议转 `undefined` 再传 API
|
||||
|
||||
## GiButton 预设类型
|
||||
|
||||
`add` | `edit` | `delete` | `search` | `reset` | `upload` | `import` | `export`
|
||||
|
||||
列表工具栏与操作列统一使用 `GiButton`,操作列常用 `size="mini"`。
|
||||
|
||||
## 表格列(TSX)
|
||||
|
||||
使用 `lang="tsx"`,从 `@arco-design/web-vue` 引入 `TableColumnData`:
|
||||
|
||||
```tsx
|
||||
const tableColumns: TableColumnData[] = [
|
||||
{
|
||||
title: '序号',
|
||||
width: 68,
|
||||
align: 'center',
|
||||
render: ({ rowIndex }) => <span>{rowIndex + 1}</span>
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true },
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: ({ record }) => (
|
||||
<GiCellStatus status={record.status} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
align: 'center',
|
||||
fixed: 'right', // 或绑定 useTable 的 fixed
|
||||
render: ({ record }) => (
|
||||
<Space>
|
||||
<GiButton type="edit" size="mini" onClick={() => onEdit(record as T.ListItem)} />
|
||||
<Popconfirm content="确定删除?" onBeforeOk={() => onDelete(record as T.ListItem)}>
|
||||
<GiButton type="delete" size="mini" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await xxxApi.list({ ...query })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<XxxVO> = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '状态', key: 'status', render: (row) => h(NTag, { type: tagOf(row.status), size: 'small' }, { default: () => STATUS_NAMES[row.status] }) },
|
||||
{ title: '操作', key: 'actions', render: (row) => h(NSpace, null, { default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => '编辑' })
|
||||
] }) }
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
高度链必须完整:.page-card(撑满内容区)→ .page-card__body → n-data-table[flex-height],
|
||||
每一层都要 min-height: 0(前两层由 styles/index.scss 给)。少一层 → 分页器被顶出可视区。
|
||||
-->
|
||||
<NCard title="xxx" :bordered="false" class="page-card">
|
||||
<template #header-extra>
|
||||
<span class="toolbar__hint">共 {{ total }} 条</span>
|
||||
</template>
|
||||
|
||||
<!-- 工具条:筛选在左/右,主操作 type="primary" + **默认尺寸**;
|
||||
同一行控件尺寸要一致(别混 size="small",否则 28px 与 34px 参差不齐) -->
|
||||
<div class="toolbar">
|
||||
<NSpace :size="8">
|
||||
<NButton v-if="canManage" type="primary" @click="openAdd">
|
||||
新建 xxx
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NSpace :size="8">
|
||||
<NInput v-model:value="query.keyword" placeholder="搜索名称" clearable @keyup.enter="search" />
|
||||
<NButton type="primary" ghost @click="search">
|
||||
搜索
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<div class="page-card__body">
|
||||
<NDataTable
|
||||
remote
|
||||
flex-height
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:pagination="listPagination({ page: query.page, pageSize: query.pageSize, itemCount: total })"
|
||||
@update:page="(p) => { query.page = p; load() }"
|
||||
@update:page-size="(s) => { query.page = 1; query.pageSize = s; load() }"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
```
|
||||
|
||||
常用单元格组件:`GiCellAvatar`、`GiCellGender`、`GiCellStatus`(从 `@/components/GiCell` 引入或在 TSX 中直接使用)。
|
||||
### 三个必须记住的点
|
||||
|
||||
## GiPageLayout
|
||||
1. **远程分页要处理两个事件**:`@update:page` **和** `@update:page-size`,
|
||||
且都要**重新取数**。naive 的表格在 `pagination` 非 false 时会自己再切一次数据
|
||||
(内部 `flattenData`)—— 只改本地每页条数不重新取数,会出现"分页器说 50 条/页、表里还是 20 条"。
|
||||
2. **分页器容器用 `.page-card` + `.page-card__body`**(`styles/index.scss`):
|
||||
否则卡片变高会把分页器顶出可视区、怎么滚都看不到(本项目反复踩过)。
|
||||
3. **`listPagination()` 而不是手写对象**:手写时 `prefix` 的参数少一个 `?` 就报 TS2322。
|
||||
|
||||
| 属性/插槽 | 说明 |
|
||||
|-----------|------|
|
||||
| `#left` | 左侧栏(部门树、字典类型列表等) |
|
||||
| `#header` | 顶栏(少用) |
|
||||
| `margin` | 外边距模式 |
|
||||
| `collapsed` | 显示折叠按钮 |
|
||||
| `size` | 左侧宽度,如 `"280px"` |
|
||||
### 版面(2026-10 卡片式基线)
|
||||
|
||||
左栏选中后通过事件同步右侧列表,参考 `dict/index.vue` 的 `onDictTypeRefresh`。
|
||||
- **卡片外观是全局给的**(白底 + 描边 + 轻阴影 + 卡片头浅灰色带),页面**不要**再自己写边框/阴影;
|
||||
卡片之所以"浮"得起来,靠的是 `layout` 那一层的 `--xt-bg` 灰底。
|
||||
- 卡片**内部分区**用 `.section-title`(左侧蓝色小竖条)+ 浅灰小格子,**不要卡里套卡**。
|
||||
- 工具条用 `.toolbar`(自带下分隔线,把"筛选区"和"表格区"切开)。
|
||||
- 详细规则见 [code-css](../code-css/SKILL.md) 的「设计基线」「卡片式的三条规矩」「按钮的可见性」。
|
||||
|
||||
## 表单弹窗(Modal + GiForm)
|
||||
## 表单弹窗
|
||||
|
||||
```vue
|
||||
<a-modal v-model:visible="visible" :title="title" :mask-closable="false"
|
||||
@before-ok="save" @close="close">
|
||||
<GiForm ref="GiFormRef" :model-value="form" :columns="formColumns"
|
||||
@update:model-value="Object.assign(form, $event)" />
|
||||
</a-modal>
|
||||
```
|
||||
- `NModal` + `NForm` + `NFormItem`,`ref<FormInst | null>` 拿实例,提交前 `await formRef.value?.validate()`。
|
||||
- 校验规则用 `FormRules`,与后端约束对齐(**但服务端才是权威**,前端只减少无效提交)。
|
||||
- 成功后 `message.success(...)` + 关弹窗 + `load()`。
|
||||
- 危险动作(删除/停用/重置密码)用 `useDialog()` 二次确认,文案写明后果。
|
||||
|
||||
```ts
|
||||
import type { FormColumnItem } from '@/components/index'
|
||||
import { useResetReactive } from '@/hooks'
|
||||
## 权限与展示
|
||||
|
||||
const [form, resetForm] = useResetReactive({ id: '', name: '', status: '1' as Status })
|
||||
const GiFormRef = useTemplateRef<InstanceType<typeof GiForm>>('GiFormRef')
|
||||
|
||||
const add = () => {
|
||||
detailId.value = ''
|
||||
visible.value = true
|
||||
}
|
||||
const edit = async (id: string) => {
|
||||
visible.value = true
|
||||
detailId.value = id
|
||||
Object.assign(form, (await baseAPI.getDetail({ id })).data)
|
||||
}
|
||||
const close = () => {
|
||||
GiFormRef.value?.formRef?.resetFields()
|
||||
resetForm()
|
||||
}
|
||||
const save = async () => {
|
||||
const valid = await GiFormRef.value?.formRef?.validate()
|
||||
if (valid) return false
|
||||
// await baseAPI.add(form) 或 baseAPI.update(form)
|
||||
emit('save-success')
|
||||
return true
|
||||
}
|
||||
|
||||
defineExpose({ add, edit })
|
||||
```
|
||||
|
||||
`formColumns` 常用 `type`:`input`、`select`、`tree-select`、`radio-group`、`switch`、`textarea`。校验可用 `required`、`rules`;正则见 `@/utils/regexp`。
|
||||
|
||||
## 字典下拉
|
||||
|
||||
```ts
|
||||
const { dictData } = useDict(['STATUS'] as const)
|
||||
// 模板:<a-select :options="dictData.STATUS" />
|
||||
```
|
||||
|
||||
## 表格增强方案选型
|
||||
|
||||
| 需求 | 方案 |
|
||||
|------|------|
|
||||
| 内置斑马纹/全屏/列设置 | `GiTable` |
|
||||
| 继续用 `a-table` + 工具栏/列设置 | `GiTableSetting` 包裹(见 `views/table/custom3`) |
|
||||
| 简单列表 | `a-table` + `useTable`(系统模块主流) |
|
||||
|
||||
## 检查清单
|
||||
|
||||
- [ ] `defineOptions({ name })` 已设置
|
||||
- [ ] `row-key="id"` 与 `useTable` 的 `rowKey` 一致
|
||||
- [ ] 删除走 `onDelete` / `onBatchDelete` 或自定义 `Popconfirm` + API
|
||||
- [ ] 弹窗 `defineExpose({ add, edit })`,父组件 `useTemplateRef` 调用
|
||||
- [ ] 保存成功后 `emit('save-success')` 并触发 `search`
|
||||
- 按钮显隐:`computed(() => user.has('xxx:manage'))`;**不要**用 `v-hasPerm`。
|
||||
- 状态色:`tagOf(row.status)`;状态文案映射放 `apis/xt/<域>.ts` 里与接口同处。
|
||||
- 敏感字段(手机号/证件号)后端只回**脱敏值** → 编辑时**无法回填原文**,要改就得重填(照抄账号页的写法)。
|
||||
|
||||
Reference in New Issue
Block a user