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:
2026-10-10 16:48:46 +08:00
parent 0bbac7d228
commit 5b877451eb
270 changed files with 12964 additions and 25889 deletions
+14 -11
View File
@@ -2,20 +2,25 @@
本目录为 AI 编码助手规范的**唯一来源**(Single Source of Truth),适用于 Cursor、OpenCode、Codex 等工具。 本目录为 AI 编码助手规范的**唯一来源**(Single Source of Truth),适用于 Cursor、OpenCode、Codex 等工具。
> ⚠️ **2026-10 已整体重写**:PC 端从 **Gi Admin Pro + Arco Design Vue** 换成 **纯 Vue 3 + Naive UI**。
> 上一版 skills 里的 Arco 组件名(`a-table` / `a-form` / `a-modal`)、`v-hasPerm`、auto-import、
> Stylelint、Gi 的 `useTable/usePagination` **全部不再适用** —— 照着写会编译不过或静默失效。
> 唯一保留原样的是 `git-commit`(与框架无关)。
## 结构 ## 结构
``` ```
.agents/ .agents/
├── rules/ ├── rules/
│ └── agent.md # 唯一常驻规则(Cursor 经 AGENTS.md 内联注入) │ └── agent.md # 唯一常驻规则(AGENTS.md 内联注入)
└── skills/ # 按需加载 └── skills/ # 按需加载
├── code-ts/SKILL.md ├── code-ts/SKILL.md # TS 命名 / 导入 / 显式 import
├── code-vue/SKILL.md ├── code-vue/SKILL.md # SFC 写法与模板约定
├── code-css/SKILL.md ├── code-css/SKILL.md # SCSS 与样式边界
├── project-guide/SKILL.md ├── project-guide/SKILL.md # 技术栈 / 目录 / 七条最容易写错的约定
├── project-crud/SKILL.md ├── project-crud/SKILL.md # 列表 + 表单的标准写法
├── project-api/SKILL.md ├── project-api/SKILL.md # 接口调用(没有 Mock)
└── git-commit/SKILL.md └── git-commit/SKILL.md # 提交规范(与框架无关)
``` ```
## 工具适配 ## 工具适配
@@ -23,7 +28,5 @@
| 工具 | 适配方式 | | 工具 | 适配方式 |
|------|----------| |------|----------|
| Cursor | `AGENTS.md`(内联 behavior 规则 + 技能索引)+ `.agents/skills/`(按需) | | Cursor | `AGENTS.md`(内联 behavior 规则 + 技能索引)+ `.agents/skills/`(按需) |
| OpenCode | `AGENTS.md` + `opencode.json` + `.agents/rules/` + `.agents/skills/` | | OpenCode | `AGENTS.md` + `.agents/rules/` + `.agents/skills/` |
| Codex | `AGENTS.md` + `.agents/skills/` | | Codex | `AGENTS.md` + `.agents/skills/` |
修改 `agent.md` 后请同步 `AGENTS.md` 的「常驻规则」一节;编码规范改对应 skill,不要重复写入 `AGENTS.md`。
+17 -16
View File
@@ -1,6 +1,6 @@
# Agent 行为准则 # Agent 行为准则
> 适用于 Cursor、OpenCode、Codex 等 AI 编码助手。Cursor 经 `AGENTS.md` 内联本节每轮注入。 > 适用于 Cursor、OpenCode、Codex 等 AI 编码助手。AGENTS.md 内联本节每轮注入。
## 通用要求 ## 通用要求
@@ -11,26 +11,27 @@
## 应当 ## 应当
- 改代码前先阅读项目既有实现,保持风格一致。 - 改代码前先阅读项目既有实现(`src/views/` 里同类页面就是最好的模板),保持风格一致。
- 优先复用项目内已有组件、composables、工具函数与 API 封装。 - 优先复用已有组件、composables(`src/composables/`)、工具函数(`src/utils/`)与 API 封装(`src/apis/xt/`)。
- 任务完成后,视情况运行 lint / typecheck / test(与改动相关时)。 - 任务完成后运行 `pnpm typecheck`(**当前 0 错,有报错就是新引入的**)与 `pnpm lint`。
- **按需读取** skills,不要预加载全部;写代码前按任务类型加载对应 skill(见 `AGENTS.md` 技能索引)。 - **按需读取** skills,不要预加载全部。
## 不应 ## 不应
- 为已自动导入的 API / 组件写冗余 import。 - ❌ 写 **Arco Design** 的组件与属性(`a-table` / `a-form` / `a-modal` / `arcoblue` …)—— 项目已不用 Arco。
- 引入与项目技术栈不符的 UI 库或全新抽象层。 - ❌ 用 `v-hasPerm` 指令或指望 **auto-import**:本仓库组件与 API **全部显式 import**。
- 猜测不存在的 API 路径、响应结构或配置项;以项目代码与类型定义为准。 - ❌ 把 `apis/xt/*.ts` 里映射的 **Arco 色词**直接塞给 `n-tag`(会静默变灰,见 `utils/tag.ts`)。
- 在用户未要求时添加测试、文档或无关注释。 - ❌ 猜测不存在的接口路径、响应结构或配置项;以 `src/apis/xt/` 与后端契约为准。
- ❌ 在用户未要求时添加测试、文档或无关注释。
## 编码前必读 skill ## 编码前必读 skill
| 任务 | 技能 | | 任务 | 技能 |
|------|------| |------|------|
| 写/改 `.vue` | `code-vue` | | 写/改 `.vue` | `skills/code-vue/SKILL.md` |
| 写/改样式 | `code-css` | | 写/改样式 | `skills/code-css/SKILL.md` |
| 写/改 `.ts` / composable | `code-ts` | | 写/改 `.ts` / composable | `skills/code-ts/SKILL.md` |
| 项目约定、Gi 组件 | `project-guide` | | 项目约定、Naive UI 组件与目录 | `skills/project-guide/SKILL.md` |
| CRUD 列表/表单 | `project-crud` | | CRUD 列表/表单 | `skills/project-crud/SKILL.md` |
| API / Mock | `project-api` | | 接口调用 | `skills/project-api/SKILL.md` |
| 提交代码 | `git-commit` | | git commit | `skills/git-commit/SKILL.md` |
+99 -23
View File
@@ -1,36 +1,112 @@
--- ---
name: code-css name: code-css
description: Vue SFC 样式块、BEM 命名、Stylelint 与 scoped 约定。编写或修改组件样式(.vue style、scss、less、css)时加载本技能。 description: SCSS 写法、样式作用域与全局样式的边界。编写或修改 .vue 的 style 块、.scss 文件时加载本技能。
--- ---
# CSS 规范(Vue 3) # 样式规范(SCSS + Naive UI)
## SFC 样式块 > ⚠️ **Stylelint 已随 2026-10 重搭移除**(`pnpm lint:style` 不存在了)。样式只受 ESLint 与人工约定约束。
- 位于 `<script>` 之后;默认 **scoped**;全局样式须在注释中说明原因。 ## 放哪里
- 预处理器按项目约定,同一组件内保持一致。
## 命名(BEM) | 场景 | 位置 |
|---|---|
| 组件的具体样式 | 该 SFC 的 `<style scoped lang="scss">` |
| **跨页面**的版面基线(背景、卡片外观、卡片间距、滚动、分页器容器、区块标题) | `src/styles/index.scss`(**只放跨页面的东西**) |
| 组件外观的主题变量(Card / Button / DataTable 的描边、圆角、阴影) | `src/App.vue` 的 `themeOverrides` |
- 自定义 class:**kebab-case** + **BEM**(`block`、`block__element`、`block--modifier`)。 ## 设计基线(2026-10 卡片式改造后:**页面只引用,不再各写一套**)
- 一个 SFC 通常一个 Block;元素用 `__`,修饰符用 `--`;**禁止** `block__a__b` 链式元素。
- 第三方 UI 类名、全局工具类不在 BEM 约束内。
```scss `src/styles/index.scss` 的 `:root` 里定义,页面直接 `var()`:
.user-card {
&__avatar { width: 48px; }
&__badge--vip { color: var(--color-warning); }
}
```
## 编写原则 | 变量 | 值 | 用途 |
|---|---|---|
| `--xt-header-h` | `56px` | 顶栏高度(`.app-shell__header` 与 `.page-card` 高度算式**共用**) |
| `--xt-content-pad` | `16px` | 内容区内边距(同上,两处共用,别写死数值) |
| `--xt-gap` | `16px` | 卡片/区域之间的间距 |
| `--xt-bg` | `#f2f4f7` | 页面底色(**卡片要靠它才浮得起来**) |
| `--xt-card-border` / `--xt-card-border-weak` | `#e5e8ee` / `#eef1f5` | 卡片描边 / 嵌套卡片描边 |
| `--xt-card-radius` | `10px` | 卡片圆角 |
| `--xt-card-pad-x` / `--xt-card-pad-y` | `18px` / `16px` | 卡片内容区左右 / 上下内边距(**别在页面里写死**,见下) |
| `--xt-band-bg` | `#f7f8fa` | 浅灰色带:卡片头 / 表头 / 卡片内的子格子 |
| `--xt-text` / `--xt-text-2` / `--xt-text-3` | `#1f2229` / `#667085` / `#98a2b3` | 主 / 次 / 弱文字 |
- 颜色、间距、字号优先复用项目 CSS 变量或设计令牌。 ### ⚠️ 卡片内边距:**不要让页面自己写**
- 避免内联 `style`(动态样式除外)和滥用 `!important`。
- 遵循项目 Stylelint;改动后视情况运行 `lint:style`。
## 禁止 Naive 的规则是 `cB("card-content", "padding: 0 var(--n-padding-left) var(--n-padding-bottom) …")`
外加 `c("&:first-child", "padding-top: var(--n-padding-top)")` ——
**`padding-top` 只给"内容区是长子"(也就是没有卡片头)的卡片**。
所以有标题的卡片,内容区 `padding-top` 是 **0**,第一行内容直接贴在标题下面那条灰条上。
- ❌ 自定义 class 用 camelCase 或非语义命名 全局 `.n-card.n-card > .n-card-content { padding: var(--xt-card-pad-y) var(--xt-card-pad-x) }`
- ❌ BEM 元素链式嵌套(`block__a__b`) 已经统一处理掉了。**但它是普通样式,会被内联样式盖掉** ——
- ❌ 大段内联 style 或过多 `:deep()` 堆叠 页面里写 `:content-style="{ padding: '12px' }"` 会让那张卡片比邻居紧一圈
(而且那个写法只解决左右,不管上面这件事)。要调内边距就改 `--xt-card-pad-x/y`。
> 📌 排查手法:`getComputedStyle(card.querySelector(':scope > .n-card-content')).paddingTop`
> 应当是 `16px`;是 `0` 说明没匹配上,是别的值说明有内联样式或更具体的选择器在盖。
现成的全局类(**优先复用,不要另起名字**):
| 类 | 用途 |
|---|---|
| `.page-plain` | 普通(非列表)页根节点:flex 列 + `--xt-gap` 间距 |
| `.page-card` + `.page-card__body` | **列表页专用**高度链:卡片撑满内容区 → 表体自滚 → 分页器钉底 |
| `.toolbar` / `.toolbar--plain` | 筛选与动作行(自带下分隔线 / 去掉分隔线) |
| `.toolbar__hint` | 工具条里的灰色小字提示 |
| `.section-title` + `.section-title__note` | 卡片**内部**次级区块标题(左侧蓝色小竖条 + 右侧灰色说明) |
## 卡片式的三条规矩
1. **卡片外观是全局给的**:`.n-card.n-card` 统一白底 + 描边 + 轻阴影 + 卡片头浅灰色带
(权重写成 `.n-card.n-card` 是因为 naive 的样式由 css-render **运行时注入**,先后顺序不保证)。
页面**不要再**自己给卡片写边框/阴影。
2. **卡里不要套卡**:卡片内部要分区时,用浅灰底(`--xt-band-bg`)+ 弱描边(`--xt-card-border-weak`)
的小格子,或 `.section-title` + `1px` 分隔线。嵌套的 `NCard` 已被全局降级为无阴影 + 无色带,
但布局上仍然容易糊成一片。
3. **区域间隔靠"色带 + 描边",不是靠加大 margin**:卡片之间 `--xt-gap`,卡片内区块 12~20px。
上一版的教训是把卡片写成 `:bordered="false"` + 内容区白底 → 白卡片浮在白底上,整页没有任何边界。
## 按钮的可见性
- 页面**主操作**(新建 / 导入 / 批量通过…)用 `type="primary"`,**默认尺寸**(不要 `size="small"`)。
- **同一行内控件尺寸必须一致**:一行里混用 `small`(28px) 与默认(34px) 会参差不齐。
工具条统一默认尺寸;表格行内动作保持 `text` + `size="small"`。
- 默认按钮的描边已在 `App.vue` 里加深(`#d0d5dd`)—— 原来用的 `common.borderColor`(`#e0e0e6`)
在白色卡片上几乎看不见,这是"按钮不明显"的主因。
- ⚠️ naive 的按钮**边框画在子元素 `.n-button__border` 上**,不是根节点;
用 `getComputedStyle(btn).borderTopWidth` 检查会得到 `0px`,别据此判断"边框没生效"。
## 覆盖 naive 内部类之前:**先确认真实类名**
⚠️ naive 的类名**不是一套统一规则**,同一个组件里两种命名混着用:
| 真实类名 | 由什么生成 |
|---|---|
| `.n-card-header` / `.n-card-content` | `cB("card-header")` / `cB("card-content")` —— **单短横** |
| `.n-button__border` / `.n-button__content` | `cE("border")` / `cE("content")` —— **双下划线** |
所以"想当然按 BEM 写"会**静默失效**:`:deep(.n-card__content)` 匹配不到任何元素,
不报错、不影响编译,只是那条样式**什么也没做**。
> 2026-10 实际踩到:`.page-card` 的高度链第一环(卡片内容区要是能撑满的 flex 列)
> 就写在 `:deep(.n-card__content)` 里 —— 结果「操作日志」的表格体高度算成 **0**,
> 行**在 DOM 里、数据也查得到,屏幕上一行都看不见**。
>
> **正确做法**:在 DevTools 里选中那个元素,看它实际的 class;或者去
> `node_modules/naive-ui/es/<组件>/src/styles/index.cssr.mjs` 里搜一眼。
> 顺带:`.n-card-content` 与 `.n-card-header` 这两个名字已经被 `styles/index.scss` 用到,
> 改卡片相关样式时以那里为准。
## 约定
- class 用 **kebab-case**;同一块的子元素用 `块__元素` 风格(`task__tabs`、`join__hint`),
与现有页面保持一致即可,**不要引入新的方法论或工具链**。
- 颜色优先用 Naive UI 的 CSS 变量 / 主题变量,**不要**写死 Arco 时代的 `--arco-*` 变量(已不存在)。
- 选择器保持扁平(≤2 层),避免 `!important`;确实要覆盖 naive 内部类时**加注释说明原因**。
## 踩过的坑(照抄现有写法即可)
1. **列表页卡片要能自己滚**:分页器被裁出可视区是本项目反复踩的问题 ——
列表页统一用 `styles/index.scss` 里的 `.page-card` 容器(卡片撑满内容区 → 表体自己滚 → 分页器钉在卡片底部)。
2. 页面根节点用 `NPagination`/`NDataTable` 时别自己写 `overflow: hidden`,会把分页器裁掉。
+23 -20
View File
@@ -1,38 +1,41 @@
--- ---
name: code-ts name: code-ts
description: Vue 3 + TypeScript 命名、导入顺序、目录组织与 Lint 约定。编写或修改 .ts/.js 代码、新增 composable 或工具函数时加载本技能。 description: Vue 3 + TypeScript 命名、导入顺序、显式 import 与目录组织。编写或修改 .ts/.js 代码、新增 composable 或工具函数时加载本技能。
--- ---
# TypeScript 规范(Vue 3) # TypeScript 规范(Vue 3 + Naive UI)
Vue SFC 见 [code-vue](../code-vue/SKILL.md),样式见 [code-css](../code-css/SKILL.md)。 Vue SFC 见 [code-vue](../code-vue/SKILL.md),样式见 [code-css](../code-css/SKILL.md)。
## 命名 ## 命名
- 组件 / 文件名:**PascalCase**;变量 / 函数:**camelCase**(事件处理推荐 `handle` 前缀)。 - 组件文件:**PascalCase**(`AppIcon.vue`、`AuditDetailDrawer.vue`);工具/接口模块:**kebab-case 或小写**(`export-task.ts`、`acting-tenant.ts`)。
- 常量:需要时 **UPPER_SNAKE_CASE**;Composables:`use` 前缀。 - 变量 / 函数:**camelCase**;事件处理用 `handle` 前缀;Composables 用 `use` 前缀。
- Props camelCase;模板事件名与 emits 定义 **kebab-case**。 - 常量:`UPPER_SNAKE_CASE`(如 `TENANT_STATUS`);展示映射放在 `src/apis/xt/<域>.ts` 里与接口同处。
- 样式 class:**kebab-case** + BEM(见 [code-css](../code-css/SKILL.md));全局组件前缀遵循项目统一约定。 - 样式 class:kebab-case(见 [code-css](../code-css/SKILL.md))。
## TypeScript ## TypeScript
- `import type` 与值导入分离;对象结构优先 `interface`,联合/工具类型用 `type`。 - `import type { ... }` 与值导入**分开写**(ESLint `@antfu` 会强制)。
- 对象结构优先 `interface`;联合/工具类型用 `type`。
- Props 默认值:`withDefaults(defineProps<Type>(), { ... })`;避免 `any`。 - Props 默认值:`withDefaults(defineProps<Type>(), { ... })`;避免 `any`。
- 公共类型集中管理;领域类型靠近业务模块。 - **接口出入参类型直接复用 `src/apis/xt/` 里的契约类型**,不要手写一份字段。
## 导入顺序 ## 导入顺序(ESLint 会排)
1. 类型导入 → 2. 第三方 → 3. Vue/官方库 → 4. 项目内部(composables、组件、utils、样式) 1. 类型导入(`import type`) → 2. 第三方(`vue` / `naive-ui` / `axios`) → 3. 项目内部(`@/apis`、`@/stores`、`@/utils`、`@/icons`、`@/composables`)
不要重复 import 已自动注册的符号(类型除外)。 ⚠️ **本仓库没有 auto-import**:naive-ui 组件、Vue API(`ref`/`computed`/`h`…)、工具函数**必须显式 import**。
不要写"为已自动导入的 API 写冗余 import"这类旧规范 —— 那是上一版(Gi 模板)的约定,现在写了才不报错。
## 组织 ## 常用内部模块
- 遵循项目现有目录划分;单文件建议不超过 400 行,超出应拆分。 | 模块 | 用途 |
- 可复用 UI 放组件目录;跨页面逻辑抽 composables;纯函数抽 utils。 |---|---|
- 路径别名遵循项目配置,避免 `../../../` 穿越。 | `@/utils/http.ts` | axios 实例:成功直接 resolve `data`、失败弹提示、带 `Authorization` 与 `X-Tenant-Id` |
| `@/utils/naive.ts` | 全局 `message`(在组件外用,如 http 拦截器) |
## Lint | `@/utils/tag.ts` | `tagOf()`:状态色词 → `n-tag` 的 `type`(**必须过这一层**) |
| `@/utils/format.ts` | `fmtDate` 等格式化 |
- 遵循项目 ESLint、Stylelint、tsconfig,不擅自关闭规则。 | `@/composables/pagination.ts` | `listPagination()`:列表页分页器统一参数 |
- 必要的 `eslint-disable` / `@ts-expect-error` 须注释说明原因。 | `@/stores/user.ts` | `useUserStore()`:`has('码')` / `hasAny(...)` / `menus` / `isPlatform` |
| `@/stores/acting-tenant.ts` | 平台运营的「当前学校」(请求头 `X-Tenant-Id`) |
+74 -49
View File
@@ -1,67 +1,92 @@
--- ---
name: code-vue name: code-vue
description: Vue 3 Composition API、SFC 块顺序、defineEmits、useTemplateRef、响应式约定。编写或修改 .vue 文件时加载本技能。 description: Vue 3 Composition API、SFC 块顺序、Naive UI 组件显式导入、props/emits 与模板约定。编写或修改 .vue 文件时加载本技能。
--- ---
# Vue 规范(Vue 3) # Vue 规范(Vue 3 + Naive UI)
适用于 Composition API + `<script setup lang="ts">`。样式见 [code-css](../code-css/SKILL.md)。 适用于 Composition API + `<script setup lang="ts">`。样式见 [code-css](../code-css/SKILL.md)。
## 基础 ## 基础
- 新组件一律 **Composition API** + `<script setup>`,禁止 Options API。 - 新组件一律 **Composition API** + `<script setup lang="ts">`,禁止 Options API。
- 需要组件名时用 `defineOptions({ name: 'ComponentName' })`;路由页 `name` 与路由配置一致。 - 块顺序:`<template>` → `<script setup>` → `<style scoped>`。
- 块顺序:`<template>` → `<script setup>` → `<style>`(遵循项目 ESLint block-order)。 - 需要组件名时用 `defineOptions({ name: 'XxxYyy' })`。
## Script 内顺序 ## Script 内顺序
类型导入 → 第三方 → Vue API → 项目模块 → `defineOptions` → `defineModel` → `defineProps` + `withDefaults` → `defineEmits` → `defineSlots` → 常量/函数 → `ref`/`reactive` → `computed` → 方法 → 生命周期/`watch` → `defineExpose` 类型导入 → 第三方 → Vue API → 项目模块 → `defineOptions` → `defineProps` + `withDefaults` → `defineEmits` → 常量/函数 → `ref`/`reactive` → `computed` → 方法 → 生命周期/`watch`。
## 自动导入 ## ⚠️ 显式 import(本仓库没有 auto-import)
已自动注册的 API / 组件不要重复 import;仅需类型时用 `import type`。以 `vite.config` 与现有代码为准。 ```ts
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NCard, NDataTable, NForm, NFormItem, NInput, NModal, NTag, useMessage } from 'naive-ui'
import { computed, h, onMounted, reactive, ref } from 'vue'
import * as tenantApi from '@/apis/xt/tenant'
```
- 组件用 **PascalCase** 标签:`<NDataTable :columns="columns" :data="list" />`(**不是** `<a-table>`)。
- 弹窗/抽屉、表格、表单全部来自 **naive-ui**;主题与全局配置在 `utils/naive.ts` / `App.vue` 一侧。
### 漏 import 是**静默故障**(2026-10 实测踩到 4 个文件 6 处)
写小写标签(`<n-button>`)而忘了 import 时,Vue 把它当**未知自定义元素**渲染:
**不报错、不崩、`pnpm typecheck` 与 `pnpm lint` 全绿**,只是样式整块丢掉。
实测表现:`<n-button>` 变成无边框透明块、`<n-avatar>` 变成 18×29 的裸元素、`<n-form>` 的表单排版全丢 ——
用户看到的就是"这个页面样式坏了"。
> **统一写 PascalCase 标签**(`<NButton>`),漏 import 时 Vue 会给出
> `Failed to resolve component` 警告,比小写标签好排查。
> 另外 `python scripts/check-wiring.py` 的**第 ⑧ 项**会静态核对
> 「模板里用到的 `<n-*>` ↔ 本文件的 import」,改完 PC 端跑一下它。
## ⚠️ `n-tab-pane` 的标签必须用 `#tab` **插槽**
naive-ui 2.45.3 里,`tab="文字"` **属性**写法渲染不出任何文字(只留一个空注释节点),
表现是**整排 tab 一个字都不显示、整行高度只剩 12px** —— 筛选功能等于消失,且不报错。
```vue
<!-- ✅ 正确 -->
<NTabs v-model:value="node">
<NTabPane :name="1">
<template #tab>
初审(班主任)
</template>
</NTabPane>
</NTabs>
<!-- ❌ 不要:标签会静默消失 -->
<NTabPane :name="1" tab="初审(班主任)" />
```
原因:属性值走 `normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回
Text VNode,被当成块 `createBlock(vnode)` 处理;插槽那条分支返回数组,所以正常。
## 权限与状态色
```ts
const user = useUserStore()
// 没有 v-hasPerm 指令,权限判断就是一次函数调用
const canManage = computed(() => user.has('org:manage'))
```
```vue
<NTag
:type="tagOf(row.status)"
size="small"
>
{{ STATUS_NAMES[row.status] }}
</NTag>
```
⚠️ **不要**把 `apis/xt/*.ts` 里的 `color`(Arco 色词 `green`/`arcoblue`/`gold`)直接传给 `n-tag` 的 `type` ——
会**静默变成默认灰色**且不报错;必须过 `@/utils/tag.ts` 的 `tagOf()`。
## 模板 ## 模板
- 事件名、插槽名 **kebab-case**;组件 **PascalCase**。 - 事件名、插槽名 **kebab-case**;组件 **PascalCase**。
- 受控表单优先 `v-model`;复杂逻辑抽到 `computed` 或方法,不要写在模板里。 - 表格列用 `h()` 渲染(`NButton` / `NTag` / `AppIcon`),逻辑别堆在模板里。
- 子组件通过 props/emits 通信,避免随意访问父组件实例。 - 受控表单优先 `v-model`;复杂逻辑抽到 `computed` 或方法。
- 图标统一用 `@/icons/AppIcon.vue`(缺图标是**空占位、不报错**)。
## defineEmits - 子组件通过 props/emits 通信,避免访问父组件实例。
使用**基于类型的函数调用签名**,禁止数组 / tuple 形式:
```typescript
// ✅
const emit = defineEmits<{
(e: 'submit'): void
(e: 'change', value: string): void
}>()
// ❌ defineEmits<{ submit: [] }>() 或 tuple 形式
```
## 模板 Ref
优先 **`useTemplateRef`**(Vue 3.5+);模板 `ref` 属性值与参数字符串一致。
```typescript
const formRef = useTemplateRef<FormInstance>('formRef')
// template: <form ref="formRef">
```
## 响应式
- 原始值 `ref`,对象 `reactive`,派生 `computed`。
- 解构 reactive 注意响应性丢失,必要时 `toRefs`。
- 深拷贝优先 `structuredClone` 或项目工具,禁止 `JSON.parse(JSON.stringify())`。
## defineExpose
仅暴露父组件确实需要调用的方法或属性(如弹窗 `open`/`close`/`reset`)。
## 禁止
- ❌ Options API、重复自动导入、defineEmits tuple 形式
- ❌ 无关 `ref()` 绑定模板 ref(优先 `useTemplateRef`)
- ❌ 模板中写复杂业务逻辑
+38 -142
View File
@@ -1,162 +1,58 @@
--- ---
name: project-api name: project-api
description: 在本模板项目中新增或修改 API 接口与类型定义(本仓库已不用 Mock,见开头说明)。涉及 http、baseAPI、分页响应格式、响应体适配时使用。 description: 接口调用约定(axios 封装、apis/xt 模块、平台代管头、错误处理)。新增或修改接口调用时加载本技能。
--- ---
# API 与 Mock # 接口调用
> ⚠️ **本仓库不用 Mock(2026-09-30 核实)**:`mock/` 目录与 `vite-plugin-mock` 都已删除, > ⚠️ **本仓库没有 Mock**:`mock/` 与 `vite-plugin-mock` 都已删除。
> `.env.development` 里 `VITE_API_PREFIX = '/api/v1'`、`VITE_API_BASE_URL = 'http://localhost:8080'`, > `pnpm dev` 把 `/api` 代理到 `http://localhost:8080` → **后端不起着就没有数据**。
> `pnpm dev` 把 `/api` 代理到真实后端 —— **后端不起着就没有数据**(这不是故障)。 > (上一版这份 skill 以 Mock 为主线,那是 Gi 模板的写法,**已不适用**。)
>
> 所以本文档里所有关于 Mock 的部分(`mock/**`、`defineMock`、`getBaseApi` 生成 mock、
> Mock 目录约定)在本仓库**不适用**,保留它们只是因为 Gi 模板升级时要做差异比对。
> 新增接口照 `src/apis/xt/*.ts` 的写法:用 `@/utils/http`,响应体 `{ code, msg, data }`
> (适配层会补 `records`)已在 `@/utils/http` 统一处理,业务模块不要再自己解一层。
## 接口层结构 ## 契约
每个业务模块一个文件,例如 `src/apis/system/user.ts`: - 响应恒为 `{ code, msg, data }`;**业务错误也用 HTTP 200**,
只有 `401` / `403` / `429` / `5xx` 用**真实状态码**(别在业务码里找 `40301`)。
- `code === 0` 是成功。
## 封装(`src/utils/http.ts`)
它替业务代码做掉三件事,所以页面里几乎不出现 `code`:
1. 成功时**直接 resolve `data`** → `const vo = await getTask(id)` 拿到的就是数据;
2. 失败时**弹一次提示**并 reject 一个带 `bizCode` 的错误(要按码分支也能拿到);
3. 自动带 `Authorization: Bearer <token>` 与平台代管头 `X-Tenant-Id`。
## 写一个接口模块(`src/apis/xt/<域>.ts`)
```ts ```ts
import { getBaseApi } from '@/apis/base' import type { PageResult } from '@/apis/types'
import http from '@/utils/http' import http from '@/utils/http'
export interface ListItem { export interface XxxVO { id: number, name: string, status: number }
id: string
name: string /** 状态映射与接口同处一个文件(别处也要用,改动只有一处) */
status: Status export const XXX_STATUS: Record<number, { text: string, color: string }> = {
createTime: string 0: { text: '停用', color: 'gray' },
// ... 1: { text: '正常', color: 'green' }
} }
/** 标准 CRUD */ export function list(params: { page: number, pageSize: number, keyword?: string }) {
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/user' }) return http.get<PageResult<XxxVO>>('/xxx', { params })
/** 非标准接口单独声明 */
export function getDictDataList(params: { code: string } & Pagination) {
return http.get<PageRes<DictDataItem[]>>('/system/dict/getDictDataList', params)
} }
``` ```
## getBaseApi 约定路径 - 新域就**新建一个文件**,并在 `src/apis/xt/index.ts` 里按需导出;
- 通用类型(`PageResult`、`BizError` 等)在 `src/apis/types.ts`;
- ⚠️ 展示映射里的 `color` 是**上一版 Arco 的色词**,给 `n-tag` 用之前必须过 `@/utils/tag.ts` 的 `tagOf()`。
| 方法 | HTTP | 路径 | ## 平台代管(`X-Tenant-Id`)
|------|------|------|
| getList | GET | `{baseUrl}/getList` |
| getDetail | GET | `{baseUrl}/getDetail` |
| add | POST | `{baseUrl}/add` |
| update | POST | `{baseUrl}/update` |
| delete | POST | `{baseUrl}/delete` |
列表参数:`{ page, size, ...filters }` 平台运营的请求要带"当前学校":值存在 `src/stores/acting-tenant.ts`,
删除参数:`{ ids: string[] }` 由 `http.ts` 统一注入。**页面不要自己拼这个头**;不选学校时**不要发请求**(发了会把各租户数据混在一起)。
## 响应格式 ## 其它
```ts - 下载类接口(导出包、整理结果)必须走 `fetch`/axios **取 blob**:接口要鉴权,
// 单条/布尔 直接 `window.open` 或 `<a href>` 会 401,用户看到的是"下载没反应"。
interface ApiRes<T> { - 签名 URL(预览图片等)**每次现取**,不要缓存进本地状态或存库(有有效期)。
code: number
data: T
message: string
success: boolean
}
// 分页列表 data 结构
interface PageRes<T> {
records: T
total: number
}
```
`useTable` 同时兼容 `data` 为数组或 `PageRes` 两种形态。
## http 封装
- 实例:`src/utils/http.ts`,`baseURL` = `import.meta.env.VITE_API_PREFIX`
- 开发环境 `.env.development`:`VITE_API_PREFIX = '/mock'`
- Token:请求头 `token` 字段(`getToken()`)
- 业务失败:`success === false` 时 Message/Notification 提示并 reject
- 401:跳转 `/login`
```ts
import http from '@/utils/http'
http.get<PageRes<Item[]>>('/path', { page: 1, size: 10 })
http.post<boolean>('/path', { ids: ['1'] })
```
## Mock 开发流程
### 1. 静态数据
在 `mock/_data/` 下维护 JSON/TS 数据,如 `system_dict.ts`。
### 2. 路由 Mock
在 `mock/system/`(或对应模块)新建文件:
```ts
import { defineMock } from '../_base'
import { getBaseApi, getDelayTime, resultError, resultSuccess } from '../_utils'
export default defineMock([
...getBaseApi({
baseUrl: '/system/xxx',
getListData: () => resultSuccess({ total: list.length, records: list }),
getDetailData: (query) => {
const obj = list.find((i) => i.id === query.id)
return obj ? resultSuccess(obj) : resultError(null, '未找到', 400)
}
}),
{
url: '/system/xxx/customAction',
method: 'get',
timeout: getDelayTime(),
response: ({ query }) => {
// 筛选、分页后返回
return resultSuccess({ total, records })
}
}
])
```
### 3. 工具函数
| 函数 | 用途 |
|------|------|
| `resultSuccess(data)` | `{ code:200, success:true, data, message }` |
| `resultError(data, message, code?)` | 失败响应 |
| `getDelayTime()` | 50–300ms 随机延迟 |
| `getBaseApi({ baseUrl, getListData, ... })` | 生成标准 CRUD mock |
`defineMock` 会自动给 url 加前缀 `/mock`,与 `VITE_API_PREFIX` 对应。
### 4. 注册
确保 mock 文件被 `mock/index` 或同级聚合引入(遵循现有 `mock/system/*.ts` 模式)。
## 新增模块检查清单
- [ ] `src/apis/{module}/{name}.ts`:类型 + `baseAPI` 或自定义方法
- [ ] `mock/{module}/{name}.ts`:与接口路径一致
- [ ] 列表 Mock 返回 `{ total, records }`
- [ ] 字段类型与 `Status`、`Gender` 等全局类型对齐
- [ ] 在页面通过 `useTable({ listAPI, deleteAPI })` 接入
## 自定义 listAPI 示例
```ts
useTable({
listAPI: (page) => getDictDataList({
...page,
code: dictCode.value,
name: query.name || undefined,
status: query.status === '' ? undefined : query.status
}),
immediate: false
})
```
`immediate: false` 用于依赖左侧选中项后再 `search()` 的场景。
+100 -193
View File
@@ -1,214 +1,121 @@
--- ---
name: project-crud 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 ```vue
<template> <script setup lang="ts">
<GiPageLayout margin collapsed> import type { DataTableColumns } from 'naive-ui'
<template #left><!-- 可选:树/分类 --></template> 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"> const message = useMessage()
<a-space wrap> const loading = ref(false)
<GiButton type="add" @click="onAdd" /> const list = ref<XxxVO[]>([])
<GiButton type="delete" @click="onBatchDelete" /> const query = reactive({ page: 1, pageSize: 20, keyword: '' })
</a-space> const total = ref(0)
<a-space wrap>
<!-- 搜索表单项 -->
<GiButton type="search" @click="search" />
<GiButton type="reset" @click="reset" />
</a-space>
</a-row>
<a-table async function load() {
class="g-table" loading.value = true
row-key="id" try {
:loading="loading" const res = await xxxApi.list({ ...query })
:data="tableData" list.value = res.list
:columns="tableColumns" total.value = res.total
:bordered="{ cell: true }" } finally {
:scroll="{ x: '100%', y: '100%', minWidth: 1200 }" loading.value = false
: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>
)
} }
}
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。
| 属性/插槽 | 说明 | ### 版面(2026-10 卡片式基线)
|-----------|------|
| `#left` | 左侧栏(部门树、字典类型列表等) |
| `#header` | 顶栏(少用) |
| `margin` | 外边距模式 |
| `collapsed` | 显示折叠按钮 |
| `size` | 左侧宽度,如 `"280px"` |
左栏选中后通过事件同步右侧列表,参考 `dict/index.vue` 的 `onDictTypeRefresh`。 - **卡片外观是全局给的**(白底 + 描边 + 轻阴影 + 卡片头浅灰色带),页面**不要**再自己写边框/阴影;
卡片之所以"浮"得起来,靠的是 `layout` 那一层的 `--xt-bg` 灰底。
- 卡片**内部分区**用 `.section-title`(左侧蓝色小竖条)+ 浅灰小格子,**不要卡里套卡**。
- 工具条用 `.toolbar`(自带下分隔线,把"筛选区"和"表格区"切开)。
- 详细规则见 [code-css](../code-css/SKILL.md) 的「设计基线」「卡片式的三条规矩」「按钮的可见性」。
## 表单弹窗(Modal + GiForm) ## 表单弹窗
```vue - `NModal` + `NForm` + `NFormItem`,`ref<FormInst | null>` 拿实例,提交前 `await formRef.value?.validate()`。
<a-modal v-model:visible="visible" :title="title" :mask-closable="false" - 校验规则用 `FormRules`,与后端约束对齐(**但服务端才是权威**,前端只减少无效提交)。
@before-ok="save" @close="close"> - 成功后 `message.success(...)` + 关弹窗 + `load()`。
<GiForm ref="GiFormRef" :model-value="form" :columns="formColumns" - 危险动作(删除/停用/重置密码)用 `useDialog()` 二次确认,文案写明后果。
@update:model-value="Object.assign(form, $event)" />
</a-modal>
```
```ts ## 权限与展示
import type { FormColumnItem } from '@/components/index'
import { useResetReactive } from '@/hooks'
const [form, resetForm] = useResetReactive({ id: '', name: '', status: '1' as Status }) - 按钮显隐:`computed(() => user.has('xxx:manage'))`;**不要**用 `v-hasPerm`。
const GiFormRef = useTemplateRef<InstanceType<typeof GiForm>>('GiFormRef') - 状态色:`tagOf(row.status)`;状态文案映射放 `apis/xt/<域>.ts` 里与接口同处。
- 敏感字段(手机号/证件号)后端只回**脱敏值** → 编辑时**无法回填原文**,要改就得重填(照抄账号页的写法)。
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`
+56 -155
View File
@@ -1,171 +1,72 @@
--- ---
name: project-guide name: project-guide
description: 后台管理模板的项目总览与前端强制规范。涵盖 Vue 3 + Arco Design + Gi 组件库、目录结构、自动导入、CRUD/API 约定与代码风格。在当前仓库内开发、新增页面、查阅架构或不确定项目规范时使用。 description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及七条最容易写错的约定。开始任何 PC 端改动前加载本技能。
--- ---
# 项目开发指南 # 项目总览(PC 管理端)
> **适用范围**:本仓库 `src/**`、`mock/**`
> **规范来源**:`AGENTS.md` 常驻行为准则 + 本 skill(项目细节);编码规范按需读 `code-vue` / `code-css` / `code-ts` skill
## 本项目 Agent 约定
- 优先复用 `Gi*`、`hooks`、`getBaseApi`、现有 mock 工具;新增页面/接口对齐参考页与 `getBaseApi` 约定。
- API / Mock 以 `src/apis`、`mock` 与类型定义为准,不要猜测路径。
- 涉及 CRUD、API、Mock 时按需读取对应 skill;写 `.vue` / 样式 / TS 前读取 [code-vue](../code-vue/SKILL.md)、[code-css](../code-css/SKILL.md)、[code-ts](../code-ts/SKILL.md)。
- 改动后视情况运行 `pnpm typecheck`、`pnpm lint`、`pnpm lint:style`。
## 技术栈 ## 技术栈
| 类别 | 选型 | | 项 | 值 |
|------|------| |---|---|
| 框架 | Vue 3.5 + TypeScript + `<script setup lang="ts">` | | 框架 | **Vue 3.5 + TypeScript + Vite 7** |
| 构建 | Vite 7 | | UI | **Naive UI 2.42**(2026-10 从 Gi Admin Pro + Arco 重搭,**已无 Arco/Gi 依赖**) |
| UI | Arco Design Vue(按需自动导入)+ 本地 `Gi*` 组件 | | 状态 | Pinia(`pinia-plugin-persistedstate`) |
| 状态 | Pinia + pinia-plugin-persistedstate | | 请求 | axios(`src/utils/http.ts` 统一封装) |
| 路由 | Vue Router 4(Hash 模式) | | 样式 | SCSS(无 Stylelint) |
| HTTP | `@/utils/http`,统一响应类型 `ApiRes<T>`、`PageRes<T>` | | 检查 | `pnpm typecheck`(vue-tsc,**当前 0 错**)、`pnpm lint`(@antfu/eslint-config) |
| 工具 | @vueuse/core、lodash-es、dayjs、xe-utils |
| Mock | vite-plugin-mock + mockjs(开发默认启用) |
每个 SFC 必须 `defineOptions({ name: 'XxxYyy' })`;通用 Vue 规范见 [code-vue](../code-vue/SKILL.md),本项目补充见下文「本项目 Vue 约定」。 ## 目录
## 路径别名
- `@` → `src/`
- `~` → 项目根目录
## 目录约定
``` ```
src/ src/
├── apis/ # 接口(按业务模块分文件,标准 CRUD 用 getBaseApi) ├── apis/xt/ 接口模块(按业务域分文件)+ types.ts(通用类型)
├── components/ # Gi* 业务组件 + 部分通用组件 ├── composables/ pagination.ts(列表页分页器统一参数)
├── hooks/ # 组合式函数(useTable、useDict 等) ├── config/ xt-routes.ts(**业务菜单表:菜单 + 路由 + 分组**)
├── hooks/app/ # 业务域 hooks(useDept、useRole) ├── icons/ AppIcon.vue + icon-data.ts(由 scripts/extract-pc-icons.py 生成)
├── stores/ # Pinia ├── layout/ AppSider / AppHeader / index.vue
├── views/ # 页面(按模块分子目录) ├── router/ index.ts(本地菜单表按后端 menuKeys 过滤后注册)
├── router/ # 路由与守卫 ├── stores/ user.ts(登录态/权限/菜单)、acting-tenant.ts(平台代管学校)、app.ts
├── utils/ # http、auth、regexp、deepClone 等 ├── styles/ index.scss(只放跨页面样式)
├── types/ # 全局类型(global.d.ts、api.d.ts) ├── utils/ http.ts / naive.ts / tag.ts / format.ts / auth.ts / rsa.ts
mock/ # vite-plugin-mock 模拟数据 └── views/ 页面(每个菜单一个目录,行内抽屉与弹窗同目录)
``` ```
页面文件命名: ## 菜单与权限模型(**后端决定可见性**)
``` - 前端 `config/xt-routes.ts` 是**唯一**菜单表:`key`(菜单键)↔ 路由 ↔ 视图 ↔ 分组。
src/views/{module}/index.vue # 列表页 - **能不能看由后端决定**:`/auth/me` 返回 `menus: string[]`,前端只按它过滤;
src/views/{module}/XxxFormModal.vue # 新增/编辑弹窗(有表单时必建) 绕过前端也只会拿到 `40301`。
src/apis/{module}/{name}.ts # 类型定义 + CRUD API(或 apis/system/user.ts) - **能不能做**看权限码:`useUserStore().has('码')`,字符串必须与后端 `PermissionDef` **逐字一致**
mock/{module}/{name}.ts # 与 API 路径一致的 Mock (`progress:viewAll` 是驼峰,**不是** `progress:view:all` —— 写错只会恒为 false,不报错)。
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
## 七条最容易写错的约定
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
`python scripts/check-wiring.py` 第 ⑧ 项会核对。
2. **没有 `v-hasPerm`**:权限判断写 `user.has('码')`。
3. **`n-tag` 的 `type` 必须过 `@/utils/tag.ts`**(Arco 色词直接透传会静默变灰)。
4. **图标要重跑脚本**:`scripts/extract-pc-icons.py` → `src/icons/icon-data.ts`(37 个);
缺图标是空占位、不报错 → 图标集升级后必须重跑。
5. **`n-tab-pane` 的标签用 `#tab` 插槽**,不要用 `tab="文字"` 属性 ——
naive-ui 2.45.3 的属性写法只渲染出空注释节点,整排 tab 一个字都不显示(不报错)。
6. **卡片式版面**:白卡片靠全局 `--xt-bg` 灰底浮起来;卡片外观由 `styles/index.scss` 统一给,
页面不要再自己写;卡片内部分区用 `.section-title` + 浅灰小格子,不要卡里套卡。
详见 [code-css](../code-css/SKILL.md) 的「设计基线」一节。
7. **品牌标是几何标**:`src/assets/images/logo.svg`(方 = 卷宗,环 = 在线)。
**不要换回文字字形标**;页签用 `public/favicon.svg`(加粗版),改一个要同步另一个。
⚠️ SVG 注释里**不能出现连续两个减号**(XML 规范禁止)→ 整个文件 not well-formed,
图片裂开而控制台一句话都不报;`python scripts/check-wiring.py` 第 ⑨ 项会核对。
## 开发
```bash
pnpm install
pnpm dev # → http://localhost:5173,/api 代理到 8080(后端不起着就没有数据)
pnpm typecheck && pnpm lint
``` ```
## 全局类型(`src/types/`) 演示账号:`admin / Admin@123` + 租户码 `demo`(**不是** Gi README 里的 `admin/123456`)。
- `Status`:`'0' | '1'`(禁用/启用,与字典 `STATUS` 一致)
- `Gender`:`'1' | '2' | '3'`
- `Pagination`:`{ page, size }`
- `ApiRes<T>`、`PageRes<T>`:统一接口响应(分页 `data` 为 `{ records, total }`)
## CRUD 列表页(强制遵循)
新建/修改表格页时,**必须**参考并遵循:
- 参考实现:`src/views/system/user/index.vue`、`UserFormModal.vue`
- Hook:`@/hooks/useTable`
- API 模板:`src/apis/system/user.ts` + `getBaseApi`
- 详细步骤:读取并遵循 [project-crud](../project-crud/SKILL.md)
核心结构:
1. `GiPageLayout` 布局(可选 `#left` 树/分类)
2. `queryParams`(reactive)+ 搜索区 + `GiButton`(search/reset)
3. `tableColumns`(`TableColumnData[]`,复杂列用 `lang="tsx"` + `render`)
4. `useTable` 绑定 `listAPI` 与 `deleteAPI`
5. `XxxFormModal` ref,`defineExpose({ add, edit })`,`@save-success="search"`
分页类型:`PageRes<T>`(`records` + `total`)。Mock 与接口详见 [project-api](../project-api/SKILL.md)。
## API 层模板
```typescript
import { getBaseApi } from '@/apis/base'
import http from '@/utils/http'
export interface ListItem {
id: string
name: string
status: Status
}
/** 标准 CRUD */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/xxx' })
/** 非标准接口 */
export function customAction(params: Pagination) {
return http.get<PageRes<Item[]>>('/system/xxx/customAction', params)
}
```
- 开发环境 `VITE_API_PREFIX = '/mock'`(`.env.development`)
- 禁止绕过 `@/utils/http` 裸用 axios
## 代码风格
- 遵循 `@antfu/eslint-config`(`eslint.config.js`):箭头参数括号、1TBS、无尾随逗号;Vue block 顺序 `[['script', 'template'], 'style']`;宏顺序 `defineOptions/defineModel/defineProps/defineEmits/defineSlots`,`defineExpose` 最后。
- 布局放 `src/layout/`,Gi 组件放 `src/components/Gi*`,业务 Hook 放 `hooks/` 或 `hooks/app/`。
- 空字符串查询条件传 API 前建议转为 `undefined`。
- 通用编码规范见 [code-ts](../code-ts/SKILL.md)、[code-vue](../code-vue/SKILL.md)、[code-css](../code-css/SKILL.md);本项目补充见下文。
## 本项目编码约定
- 路径别名:`@` → `src/`,`~` → 项目根;禁止 `../../../` 穿越。
- 组件全局前缀 **`Gi`** 仅用于 `src/components/Gi*` 全局组件。
- 目录:`src/views` 页面、`src/components` 组件、`src/apis/<domain>/` + `type.ts` 接口类型、`src/types` 公共类型、`src/hooks` composables。
- API 统一 `@/utils/http`;状态 Pinia + `pinia-plugin-persistedstate`。
- 图标 `@iconify/vue`;样式从 scss/less 导入,避免内联。
- 业务注释中文,标识符英文。
- 检查:`pnpm lint`、`pnpm lint:style`、`pnpm typecheck`。
## 本项目 Vue 约定
- 每个 SFC 必须 `defineOptions({ name: 'XxxYyy' })`,名称与路由/模块对应,并与路由表 `name` 一致以支持缓存。
- **自动导入**(`vite.config.ts`,勿重复 import):
- Vue API:`ref`、`reactive`、`computed`、`watch`、`useTemplateRef` 等
- Gi 组件:`src/components/Gi*`(如 `GiPageLayout`、`GiButton`、`GiForm`)
- Arco 组件:`a-table`、`a-modal`、`a-select` 等
- 图标:`icon-*`(Iconify `icon-park-outline`)
- Gi 组件模板用 **PascalCase**;Arco 用 `a-*` 前缀;禁止手动 import Gi/Arco(类型除外)。
- 表格列复杂时用 `lang="tsx"` + `TableColumnData[]` 的 `render`;字典字段用 `useDict(['STATUS'])` 等。
- `dayjs` 默认导入变量名必须为 **`Dayjs`**(大写),禁止小写 `dayjs`。
- 深拷贝使用 `@/utils` 的 `deepClone`,禁止 `JSON.parse(JSON.stringify())`。
- Vue 参考页:`src/views/system/user/index.vue`、`UserFormModal.vue`、`src/views/table/custom3/index.vue`
## 本项目样式
- 全局样式:`src/styles/index.scss`(变量 `var.scss`、混入 `mixin.scss`、Arco 主题 `arco-ui/`)
- Stylelint:`pnpm lint:style`
- 列表页常用工具类:`g-row-tool`(工具栏行)、`g-table`(表格)、`g-full-column`(左栏撑满)、`g-mb`(下边距)
- BEM 示例:`src/views/login/LoginLeft.vue`
## 子技能(按需阅读)
| 场景 | 技能 |
|------|------|
| Vue SFC、Emits、Ref | [code-vue](../code-vue/SKILL.md) |
| 样式、BEM | [code-css](../code-css/SKILL.md) |
| TS 命名、导入 | [code-ts](../code-ts/SKILL.md) |
| 新建 CRUD 列表页、表格、弹窗表单 | [project-crud](../project-crud/SKILL.md) |
| 新增接口、Mock、baseAPI | [project-api](../project-api/SKILL.md) |
## 参考页面
- 标准 CRUD + 左树:`src/views/system/user/index.vue`
- 左右分栏 + 字典:`src/views/system/dict/index.vue`
- GiTableSetting + a-table:`src/views/table/custom3/index.vue`
- 表单弹窗:`src/views/system/user/UserFormModal.vue`
## 禁止事项
- ❌ 绕过 `@/utils/http` 直接裸用 axios
- ❌ 表格页自行实现分页/删除逻辑,而不使用 `useTable`
- ❌ 新建列表页不参考 `system/user` 与 [project-crud](../project-crud/SKILL.md) 约定
+8
View File
@@ -28,3 +28,11 @@ coverage
*.sw? *.sw?
.stylelintcache .stylelintcache
# ⚠️ 这两个是**上游模板时代的自动生成文件**,重搭为纯 Vue 3 + Naive UI 后已经删掉:
# · `src/components.d.ts` 是 `unplugin-vue-components` 生成的全局组件声明(内容全是 Arco 组件)
# · `src/auto-import.d.ts` 同理(本项目**没有 auto-import**,组件与 API 全显式 import)
# 之所以要忽略而不是只删掉:它们曾被误从 git 恢复回来一次 —— 这种"看起来还在用 Arco"的
# 残留不会让 typecheck 失败(`skipLibCheck` 跳过 .d.ts),只会误导后来人。
src/components.d.ts
src/auto-import.d.ts
-31
View File
@@ -1,31 +0,0 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Coverage
coverage/
# Temporary files
*.tmp
*.temp
.cache/
# Editor directories and files
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS generated files
Thumbs.db
.DS_Store
# CSS preprocessors cache
.sass-cache/
.less-cache/
+42 -24
View File
@@ -1,6 +1,10 @@
# AGENTS.md # AGENTS.md
面向 AI 编码助手的项目指南。规范以 `.agents/` 为唯一来源。 面向 AI 编码助手的项目指南(PC 管理端)。规范以 `.agents/` 为唯一来源。
> ⚠️ **2026-10 已重搭**:从 **Gi Admin Pro + Arco Design Vue** 换成 **纯 Vue 3 + Naive UI 2.42**。
> 上一版基于 Gi/Arco 的 skills 已全部重写 —— **不要再按 Arco 的组件名(`a-table` / `a-form`)、
> `v-hasPerm`、auto-import、Stylelint 去写代码**。
## 常驻规则(Agent 行为准则) ## 常驻规则(Agent 行为准则)
@@ -19,36 +23,50 @@
| 写/改 `.vue` | `.agents/skills/code-vue/SKILL.md` | | 写/改 `.vue` | `.agents/skills/code-vue/SKILL.md` |
| 写/改样式 | `.agents/skills/code-css/SKILL.md` | | 写/改样式 | `.agents/skills/code-css/SKILL.md` |
| 写/改 `.ts` / composable | `.agents/skills/code-ts/SKILL.md` | | 写/改 `.ts` / composable | `.agents/skills/code-ts/SKILL.md` |
| 项目约定、Gi 组件 | `.agents/skills/project-guide/SKILL.md` | | 项目约定、Naive UI 组件与目录 | `.agents/skills/project-guide/SKILL.md` |
| CRUD 列表/表单 | `.agents/skills/project-crud/SKILL.md` | | CRUD 列表/表单 | `.agents/skills/project-crud/SKILL.md` |
| API / Mock | `.agents/skills/project-api/SKILL.md` | | 接口调用(`src/apis/xt/`) | `.agents/skills/project-api/SKILL.md` |
| 提交代码(git commit) | `.agents/skills/git-commit/SKILL.md` | | 提交代码(git commit) | `.agents/skills/git-commit/SKILL.md` |
## 按需技能(`.agents/skills/`)
| 场景 | 文件 |
|------|------|
| TS 命名、导入、Lint | `code-ts/SKILL.md` |
| Vue SFC、Emits、Ref | `code-vue/SKILL.md` |
| 样式、BEM、Stylelint | `code-css/SKILL.md` |
| 项目总览、技术栈、Gi 约定 | `project-guide/SKILL.md` |
| CRUD 列表/表单 | `project-crud/SKILL.md` |
| API 与 Mock | `project-api/SKILL.md` |
| Git 提交 | `git-commit/SKILL.md` |
## 快速入口 ## 快速入口
```bash ```bash
pnpm dev # 开发(/api 代理到 http://localhost:8080,没有 Mock) pnpm dev # 开发(/api 代理到 http://localhost:8080,没有 Mock)
pnpm build # 生产构建 pnpm build # 生产构建
pnpm typecheck # vue-tsc pnpm typecheck # vue-tsc(**当前 0 错**,有报错就是新引入的)
pnpm lint # ESLint pnpm lint # ESLint(@antfu/eslint-config)
pnpm lint:style # Stylelint
``` ```
- 路径别名:`@` → `src/`,`~` → 项目根目录 - 路径别名:`@` → `src/`
- **开发没有 Mock**:`mock/` 目录与 `vite-plugin-mock` 都已删除,`pnpm dev` 把 `/api` - **开发没有 Mock**:`pnpm dev` 把 `/api` 代理到 `http://localhost:8080`
代理到 `http://localhost:8080`(见 `vite.config.ts` 的 `server.proxy` 与 (见 `vite.config.ts` 的 `server.proxy`)→ **后端不起着就没有数据**
`.env.development` 的 `VITE_API_BASE_URL`)→ **后端不起着就没有数据**。 - 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引
我们的接口模块在 `src/apis/xt/`(`apis/system|user|…` 是 Gi 模板自带的) - 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`)
- 登录演示账号见 `README.md`(如 admin / 123456)
## 七条最容易写错的约定
1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**,
标签统一写 **PascalCase**(`<NButton>`)。
⚠️ 漏 import 是**静默故障**:写小写 `<n-button>` 而没 import 时,Vue 把它当未知自定义元素渲染,
**不报错、typecheck/lint 全绿,只是样式整块丢掉**(2026-10 实测踩到 4 个文件 6 处)。
`python scripts/check-wiring.py` 第 ⑧ 项会静态核对这件事。
2. **权限判断**:`useUserStore().has('码')` / `hasAny(...)`(**没有 `v-hasPerm` 指令**);
权限码字符串必须与后端 `PermissionDef` **逐字一致**(`progress:viewAll` 是驼峰)。
3. **状态色**:`n-tag` 的 `type` 必须过 **`@/utils/tag.ts`** 的 `tagOf()` ——
`apis/xt/*.ts` 里的展示映射仍是 Arco 色词(`green`/`arcoblue`/`gold`),
直接透传给 `n-tag` 会**静默变灰**且不报错。
4. **图标**:用 `@/icons/AppIcon.vue` + 图标名;图标数据由
`scripts/extract-pc-icons.py` 生成到 `src/icons/icon-data.ts`(37 个),
缺图标是**空占位、不报错** → **图标集升级后要重跑该脚本**。
5. **`n-tab-pane` 的标签用 `#tab` 插槽,不要用 `tab="文字"` 属性** ——
naive-ui 2.45.3 的属性写法只渲染出一个空注释节点,**整排 tab 一个字都不显示**(不报错)。
6. **卡片式版面**:白卡片靠全局的 `--xt-bg` 灰底浮起来;卡片外观(描边/阴影/圆角/卡片头色带)
由 `src/styles/index.scss` 的 `.n-card.n-card` 统一给,**页面不要再自己写**;
卡片内部分区用 `.section-title` + 浅灰小格子,**不要卡里套卡**。
工具条控件同一行尺寸要一致(统一默认尺寸,别混 `size="small"`)。
7. **品牌标**:几何标在 `src/assets/images/logo.svg`(**不要换回文字字形标** —— 形状的出处与
构造参数写在该文件注释里);页签用 `public/favicon.svg`(加粗版),改一个要同步另一个,
外加侧栏/登录页的引用尺寸与 `docs/prototype/admin-web.html` 的 `.mark`。
⚠️ **SVG 的注释里不能出现连续两个减号**(XML 规范禁止):会让整个文件 not well-formed,
表现是图片裂开、**控制台一句话都不报**,typecheck/lint 也全绿。
`python scripts/check-wiring.py` 第 ⑨ 项会核对这件事。
+45 -603
View File
@@ -1,622 +1,64 @@
<div align="center"> # PC 管理端(frontend-pc)
<img style="width: 80px;height: 80px" src="https://gitee.com/lin0716/gi-image/raw/master/gi-logo.gif"/>
<h1>Gi Admin Pro</h1>
<a href='https://gitee.com/lin0716/gi-demo/stargazers'><img src='https://gitee.com/lin0716/gi-demo/badge/star.svg?theme=dark' alt='star'></img></a>
</div>
[![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
## 简介 深学在线 · 管理端。**纯 Vue 3 + Naive UI**(2026-10 从 Gi Admin Pro + Arco Design Vue 重搭)。
**Gi Admin Pro** 是一个基于 Vue3、Vite、TypeScript、Arco Design Vue、Pinia、VueUse 等的免费中后台模版,它使用了最新的前端技术栈,内置丰富的主题配置,有着极高的代码规范,基于 mock 实现的动态数据展示,开箱即用的模板,也可用于学习参考。 > 上一版这份 README 是 Gi Admin Pro 的模板原文(含它的演示账号 `admin / 123456`),已整体替换 ——
> 那个账号在本项目里**不存在**。
> **Gi 前缀含义:** G:代表全局 i:代表我的 ## 技术栈
>
> Gi 用来定义全局组件前缀,如 GiTable、GiForm、GiPageLayout等
## 特性 | 项 | 值 |
|---|---|
| 框架 | Vue 3.5 + TypeScript + Vite 7 |
| UI | **Naive UI 2.42**(无 Arco / Gi 依赖) |
| 状态 | Pinia(`pinia-plugin-persistedstate`) |
| 请求 | axios(`src/utils/http.ts` 统一封装) |
| 样式 | SCSS(**Stylelint 已移除**,只有 ESLint) |
- **最新技术栈**:使用 Vue3 / Vite 等前端前沿技术开发,使用高效率的 npm 包管理器 ## 常用命令
- **TypeScript**:应用程序级 JavaScript 的语言
- **主题**:丰富可配置的主题、暗黑模式
- **代码规范**:丰富的规范插件及极高的代码规范
## 预览
| 平台 | 预览地址 |
| ---------- | ---------------------------------------------------------- |
| **github** | [Gi Admin Pro 预览地址](https://lin-97.github.io/gi-demo/) |
| | 账号 | 密码 |
| ------ | ----- | ------ |
| 管理员 | admin | 123456 |
| 用户 | user | 123456 |
## 代码仓库
| 平台 | 预览地址 | 仓库地址 |
| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| **gitee**(码云) | -- | <a href="https://gitee.com/lin0716/gi-demo" target="_blank">Gitee 仓库地址</a> |
| **github** | <a href="https://lin-97.github.io/gi-demo/" target="_blank">Gi Admin Pro 预览地址</a> | <a href="https://github.com/lin-97/gi-demo" target="_blank">Github 仓库地址</a> |
## 项目示例图
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/01.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/02.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/03.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/04.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/05.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/06.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/07.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/08.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/09.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/10.png" />
<img src="https://gitee.com/lin0716/gi-image/raw/master/demo/11.png" />
## 安装使用
- 安装依赖
```bash ```bash
npm install pnpm install
pnpm dev # → http://localhost:5173(/api 代理到 http://localhost:8080)
pnpm build # 生产构建 → dist/
pnpm typecheck # vue-tsc(当前 0 错)
pnpm lint # ESLint(@antfu/eslint-config)
``` ```
- 运行 - **没有 Mock**:`pnpm dev` 把 `/api` 代理到后端 8080 → **后端不起着就没有数据**
- 路径别名:`@` → `src/`
```bash ## 演示账号(本地 dev)
npm run dev
```
- 打包 | 账号 | 密码 | 租户码 | 角色 |
|---|---|---|---|
| `admin` | `Admin@123` | `demo` | 学校管理员 |
| `platform` | `Platform@123` | — | 平台运营 |
```bash > 账号在多个单位下重名时,登录页才会出现「租户码」输入框(服务端返回 `40012`)。
npm run build > 一键登录脚本:`python scripts/dev-login.py`。
```
## 开源作者 ## 目录
**Lin**
## 常见问题
**为什么安装依赖不成功?**
检查`node`版本,最好使用原生镜像`npm`
还原镜像
``` ```
npm config set registry https://registry.npmjs.org/ src/
├── apis/xt/ 接口模块(按业务域分文件)+ types.ts
├── composables/ pagination.ts(列表页分页器统一参数)
├── config/ xt-routes.ts(业务菜单表:菜单键 ↔ 路由 ↔ 视图 ↔ 分组)
├── icons/ AppIcon.vue + icon-data.ts(由 scripts/extract-pc-icons.py 生成)
├── layout/ AppSider / AppHeader / index.vue
├── router/ 本地菜单表按后端 menuKeys 过滤后注册
├── stores/ user.ts / acting-tenant.ts / app.ts
├── styles/ index.scss(只放跨页面样式)
├── utils/ http.ts / naive.ts / tag.ts / format.ts / auth.ts / rsa.ts
└── views/ 页面(每个菜单一个目录)
``` ```
**为什么选择 Arco 组件库,而不是 Element Plus?** ## 写代码前必读
[Element Plus 对比 Arco design](https://juejin.cn/post/7294219581894705190) **`AGENTS.md` 与 `.agents/`**(本目录内的规范,已按 Naive UI 重写)。四条最容易写错的:
**为什么全局组件使用前缀 Gi?** 1. **没有 auto-import** —— 组件与 API 全部显式 `import`;
2. **没有 `v-hasPerm`** —— 权限判断写 `useUserStore().has('码')`,字符串与后端 `PermissionDef` 逐字一致;
全局组件设置了按需引入,使用前缀,方便和局部组件做区分 3. **`n-tag` 的 `type` 必须过 `@/utils/tag.ts`** —— Arco 色词直接透传会静默变灰;
4. **图标集升级后重跑** `scripts/extract-pc-icons.py` —— 缺图标是空占位、不报错。
**为什么组件使用单词大写开头 (PascalCase)命名写法?**
本项目`.vue`文件名以及在模板使用中,均采用大写`开头 (PascalCase)`命名方式
参考 Vue2 官网-风格指南: https://v2.cn.vuejs.org/v2/style-guide/
组件命名:`单文件组件的文件名应该要么始终是单词大写开头 (PascalCase),要么始终是横线连接 (kebab-case)`
其他优点:方便搜索(横线连接 (kebab-case)对搜索没那么方便)
<img src="https://gitee.com/lin0716/gi-image/raw/master/comp-name.png" />
**为什么 css 类名推荐横线连接 (kebab-case)**
参考大部分大网站,都是这个命名规则,不使用: `.myClass`这种
**页面显示异常?**
**`页面必须要保留一个根元素!!!`**
**Vue3 权限管理对路由进行排序和格式化处理方式**
使用 **xe-utils** 这个 js 库,简化数据处理
[文章地址](https://juejin.cn/post/7301260557222805567)
**页面无法缓存?**
请检查页面是否配置了`name`,且名称是否与数据一致
```js
defineOptions({ name: 'AboutIndex' })
```
```js
{
path: '/about/index',
name: 'AboutIndex', // 检查name是否一致
component: () => import('@/views/about/index.vue')
}
```
## 项目规范
#### .vue 文件行数规范
一般来说,一个 .vue 文件行数建议不超过 **`400`** 行,超过建议组件化拆分
#### vue 相关的命名
```vue
<script setup lang="ts">
const isEdit = ref(false)
// 不推荐
const title = computed(() => {
return isEdit.value ? '编辑' : '新增'
})
// 推荐 能一行就尽量写一行
const title = computed(() => (isEdit.value ? '编辑' : '新增'))
</script>
```
```vue
<script setup lang="ts">
// 表单建议使用 form 命名(简洁),不必要使用 formData, 同时使用 reactive
const form = reactive({
name: '',
phone: ''
})
</script>
```
```vue
<script setup lang="ts">
// 如果属性比较多
const getInitForm = () => ({
name: '',
phone: '',
email: '',
sex: 1,
age: ''
})
const form = reactive(getInitForm())
// 重置form
const resetForm = () => {
for (const key in form) {
delete form[key]
}
Object.assign(form, getInitForm())
}
</script>
```
```vue
<script setup lang="ts">
import { useAppStore, useUserStore } from '@/stores'
import { useLoading } from '@/hooks'
// stores 或 hooks 的使用命名规则定义
const appStore = useAppStore()
const userStore = useUserStore()
const { loading, setLoading } = useLoading()
</script>
```
#### 写法技巧
尽量使用三元表达式
```js
// 优化前
let isEdit = true
let title = ''
if (isEdit) {
title = '编辑'
} else {
title = '新增'
}
// 优化后
let title = isEdit ? '编辑' : '新增'
```
善用 includes 方法
```js
// 优化前
if (type === 1 || type === 2 || type === 3) {
}
// 优化后,此种方式在vue模板也可使用
if ([1, 2, 3].includes(type)) {
}
```
使用箭头函数简化函数
```js
// 优化前
function add(num1, num2) {
return num1 + num2
}
// 优化后
const add = (num1, num2) => num1 + num2
```
尽量减少 if else if
```vue
<script setup lang="ts">
// 比例进度条颜色 尽量减少 if else if
const getProportionColor = (proportion: number) => {
if (proportion < 30) return 'danger'
if (proportion < 60) return 'warning'
return 'success'
}
</script>
```
```js
// 优化前
const status = 200
const message = ''
if (status === 200) {
message = '请求成功'
} else if (status === 404) {
message = '请求出错'
} else if (status === 500) {
message = '服务器错误'
}
// 优化后
const status = 200
const messageMap = {
200: '请求成功',
404: '请求出错',
500: '服务器错误'
}
const message = messageMap[status]
```
如果函数参数超过两个,建议优化
```vue
<script setup lang="ts">
function createUser(name, phone, age) {
console.log('姓名', name)
console.log('手机', phone)
console.log('年龄', age)
}
// 这种方式在使用的时候可读性很差,扩展性差,而且不易于维护
createUser('张三', '178****2828', 20)
function createUser2({ name, phone, age }) {
console.log('姓名', name)
console.log('手机', phone)
console.log('年龄', age)
}
// 以对象传参更直观,更好扩展和维护
createUser2({ name: '张三', phone: '178****2828', age: 20 })
</script>
```
#### 接口调用书写
**写法一**
不需要 loading,不需要错误打印的 情况
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { getUserList as getUserListApi, type UserItem } from '@/apis' // 同名可以使用别名
const userList = ref<UserItem[]>([])
const getUserList = async () => {
const res = await getUserListApi()
console.log('如果异步成功,则会打印这行文字,否则不会打印这行文字,也不会往下执行')
userList.value = res.data
}
</script>
// getUserListApi 是一个 Promise 异步函数,Promise 最后只有 成功 / 失败 两种状态 // getUserListApi 是基于 axios
封装的,在 axios 响应拦截器做了处理 // 当 res.success === false 的时候 Promise.reject(),
也就异步失败,异步失败不会往下执行
```
**写法二**
需要 loading,需要错误打印的情况
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { getUserList as getUserListApi, type UserItem } from '@/apis' // 同名可以使用别名
const loading = ref(false)
const userList = ref<UserItem[]>([])
const getUserList = async () => {
try {
loading.value = true
const res = await getUserListApi()
console.log('如果异步成功,则会打印这行文字,否则不会打印这行文字,也不会往下执行')
userList.value = res.data
} catch (error) {
console.log('如果异步失败,会打印这行文字')
// 由于 axios 在封装的时候,已经在异步失败给了失败的提示弹窗处理
// 所以这里没必要再写 Message.error(error)
} finally {
console.log('如果异步或者失败,都会打印这行代码')
loading.value = false // 可以用来处理 loading
}
}
</script>
```
**写法三**
需要 loading, 不需要错误打印(不进行错误处理)的情况
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { getUserList as getUserListApi, type UserItem } from '@/apis' // 同名可以使用别名
const loading = ref(false)
const userList = ref<UserItem[]>([])
const getUserList = async () => {
try {
loading.value = true
const res = await getUserListApi()
console.log('如果异步成功,则会打印这行文字,否则不会打印这行文字,也不会往下执行')
userList.value = res.data
} finally {
console.log('如果异步或者失败,都会打印这行代码')
loading.value = false // 可以用来处理 loading
}
}
</script>
// catch 可以省略
```
#### 全局组件--命名规范
组件命名:**单文件组件的文件名应该要么始终是单词大写开头 (PascalCase),要么始终是横线连接 (kebab-case)**
可参考 Vue2 官网-风格指南: https://v2.cn.vuejs.org/v2/style-guide/
```
GiForm.vue
GiTable.vue
GiPageLayout.vue
```
#### 局部组件--命名规范
组件命名:**单文件组件的文件名应该要么始终是单词大写开头 (PascalCase),要么始终是横线连接 (kebab-case)**
可参考 Vue2 官网-风格指南: https://v2.cn.vuejs.org/v2/style-guide/
```
Pane1.vue
Pane2.vue
PaneQuota1.vue
PaneQuota2.vue
Step1.vue
Step2.vue
AddModal.vue
EditDrawer.vue
DetailModal.vue
```
#### 文件夹命名--命名规范 (采用中划线-)
1、文件名建议只使用小写字母,不使用大写字母
2、名称较长时采用半角连接符(-)分隔
```
home/index.vue
user/index.vue
user-detail/index.vue
```
#### 弹窗组件 Modal、抽屉组件 Drawer 的一般封装
```vue
<template>
<a-modal v-model:visible="visible" :title="title" @ok="confirm">
<!-- 内容 -->
</a-modal>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
const visible = ref(false)
const detailId = ref('')
const isEdit = computed(() => !!detailId.value) // 判断是新增还是编辑模式
const title = computed(() => (isEdit.value ? '编辑' : '新增'))
const add = () => {
detailId.value = ''
visible.value = true
}
// 如果这里的参数超过两个,建议优化成对象形式
// const edit = ({ id, taskId }) = {
// console.log(id, taskId)
// }
const edit = (id: string) => {
detailId.value = id
visible.value = true
// getDetail() 回显操作
}
defineExpose({ add, edit })
const confirm = () => {
console.log('点击了确认按钮')
}
</script>
```
使用
**`模板里使用自定义组件:使用大写开头驼峰,双击好复制,对于搜索便利`**
```vue
<template>
<EditModal ref="EditModalRef"></EditModal>
</template>
<script setup lang="ts">
import EditModal from './EditModal.vue'
const EditModalRef = ref<InstanceType<typeof EditModal>>()
// 新增
const onAdd = () => {
EditModalRef.value?.add()
}
// 编辑
const onEdit = (item: PersonItem) => {
EditModalRef.value?.edit(item.id)
}
</script>
```
#### CSS 命名规范
建议采用全小写,多单词使用-连接符(参考大部分网站,包括掘金,码云等,都是采用这个规则)
或者采用`BEM`命名规范 [BEM 命名规范](https://getbem.com/naming/)
**BEM 命名规范**
```html
<div class="article">
<div class="article__body">
<button class="article__button--primary"></button>
<button class="article__button--success"></button>
</div>
</div>
```
其他:
位置 1: 使用 var(--margin) 全局 css 变量
位置 2:使用 var(--padding) 全局 css 变量
建议尽量使用全局 css 变量来开发,可以有效提高效率和团队协作
<img src="https://gitee.com/lin0716/gi-image/raw/master/gap.png" />
#### 其他规范
可参考 Vue2 官网-风格指南: https://v2.cn.vuejs.org/v2/style-guide/ , 其中一些规范也可借鉴
可参考 **Gi Admin Pro** 源码,如有更好的规范建议,可以联系作者本人
## Vue 相关
<a href="https://cn.vuejs.org/" target="_blank">Vue3 官网</a>
<a href="https://router.vuejs.org/zh/" target="_blank">Vue-Router</a>
<a href="https://cn.vitejs.dev/" target="_blank">Vite</a>
<a href="https://pinia.web3doc.top/" target="_blank">Pinia</a>
## 插件推荐
<a href="https://arco.design/vue/component/button" target="_blank">Arco Design 组件库</a>
<a href="https://dayjs.fenxianglu.cn/" target="_blank">Day.js 一个极简的 JavaScript 库,可以为现代浏览器解析、验证、操作和显示日期和时间 2K 大小</a>
<a href="https://www.lodashjs.com/" target="_blank">Lodash 一个一致性、模块化、高性能的 JavaScript 实用工具库</a>
<a href="https://vxetable.cn/xe-utils/#/" target="_blank">Xe-utils 一个 JavaScript 函数库、工具类</a>
<a href="https://vueuse.org/" target="_blank">VueUse 一个 Vue3 Hooks 库</a>
<a href="https://next.attojs.com/" target="_blank">VueRequest 一个 Vue 请求库</a>
<a href="https://mirari.cc/v-viewer/" target="_blank">V-Viewer 基于 viewer.js 的 vue 图像查看器组件,支持旋转、缩放等操作</a>
<a href="https://www.npmjs.com/package/vue-color-kit" target="_blank">Vue-Color-Kit 一个 vue3 颜色选择器组件</a>
<a href="https://vxetable.cn/#/table/start/install" target="_blank">Vxe-Table</a>
<a href="https://github.com/fslflame/vue3-request" target="_blank">vue3-request 一个专为 Vue 3 设计的异步请求处理库,它通过统一的 useRequest Hook 和强大的插件生态,彻底简化了异步操作和 API 调用的复杂性</a>
**其他**
<a href="https://vcalendar.io/" target="_blank">VCalendar 日历组件</a>
<a href="https://antoniandre.github.io/vue-cal/" target="_blank">Vue Cal 日历组件</a>
<a href="https://alfred-skyblue.github.io/vue-draggable-plus/" target="_blank">VueDraggablePlus 支持 Vue2 和 Vue3 的拖拽组件</a>
## 推荐书籍
<a href="https://vue3.chengpeiquan.com/" target="_blank">Vue 入门指南与实战案例</a>
<a href="https://jkchao.github.io/typescript-book-chinese/" target="_blank">深入理解 TypeScript</a>
<a href="https://vue3js.cn/interview/" target="_blank">前端宝典</a>
<a href="https://lhammer.cn/You-need-to-know-css/#/translucent-borders" target="_blank">Web 开发人员需要知道的 CSS 技巧</a>
<a href="https://es6.ruanyifeng.com/#README" target="_blank">阮一峰 ES6</a>
<a href="https://www.ruanyifeng.com/blog/2015/07/flex-grammar.html" target="_blank">阮一峰 flex 布局</a>
## 开源项目集合
<a href="https://vue-admin.cn/admin" target="_blank">Vue3 开源项目集合</a>
<a href="https://react-admin.cn/admin/" target="_blank">React 开源项目集合</a>
## 工具
<a href="https://c.runoob.com/" target="_blank">菜鸟工具</a>
<a href="https://carbon.now.sh/?bg=rgba%28171%2C+184%2C+195%2C+1%29&t=material&wt=none&l=auto&width=680&ds=true&dsyoff=20px&dsblur=68px&wc=true&wa=true&pv=56px&ph=56px&ln=false&fl=1&fm=Hack&fs=14px&lh=133%25&si=false&es=2x&wm=false" target="_blank">代码生成图片</a>
<a href="http://xiets.gitee.io/json-to-any-web/" target="_blank">JSON-TO-ANY 对象转 ts 类型</a>
<a href="http://49.234.61.19/tool/cssTriangle" target="_blank">在线三角形样式生成器</a>
<a href="https://go.itab.link/" target="_blank">iTab</a>
## 捐赠
<img style="width:300px" src="https://gitee.com/lin0716/gi-image/raw/master/alipay.jpg" />
+32 -30
View File
@@ -1,35 +1,37 @@
<!doctype html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0" />
<!-- 移动端浏览器地址栏跟随品牌色(与 TDesign 主色一致) -->
<meta name="theme-color" content="#0052D9" />
<meta name="description" content="职业技能等级认定 · 报名材料收集系统" />
<meta name="format-detection" content="telephone=no" />
<link rel="stylesheet" href="./static/css/loading.css" type="text/css" />
<title>填报通 · 管理端</title>
<!--
已移除模板自带的百度统计脚本:
它上报到 gi-demo 作者的统计账号,属于把本项目的访问数据发给第三方,
不是我们自己的埋点。需要统计时请接入本单位自己的方案。
-->
</head>
<body> <head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>深学在线</title>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
/* 首屏白屏时的占位,避免"看起来没在加载" */
#app-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #8a8a8a;
font-size: 14px;
}
</style>
</head>
<body>
<div id="app"> <div id="app">
<!-- 首屏骨架:在 JS 包下载并执行之前显示,避免白屏 --> <div id="app-loading">正在加载…</div>
<section class="init-box">
<div class="init-loading">
<div class="init-loading-rect"></div>
<div class="init-loading-rect"></div>
<div class="init-loading-rect"></div>
<div class="init-loading-rect"></div>
<div class="init-loading-rect"></div>
</div> </div>
</section> <script type="module" src="/src/main.ts"></script>
</div> </body>
<script type="module" src="./src/main.ts"></script>
</body>
</html> </html>
+9 -36
View File
@@ -1,7 +1,9 @@
{ {
"name": "xt-frontend-pc", "name": "xt-frontend-pc",
"type": "module", "type": "module",
"version": "1.0.0", "version": "2.0.0",
"private": true,
"description": "深学在线 · PC 管理端(Vue 3 + Naive UI)",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@@ -9,59 +11,30 @@
"preview": "vite preview --port 5050", "preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix"
"lint:style": "stylelint \"**/*.{vue,scss,css}\" --cache",
"lint:style:fix": "stylelint \"**/*.{vue,scss,css}\" --cache --fix"
}, },
"dependencies": { "dependencies": {
"@arco-design/color": "^0.4.0", "@iconify-json/icon-park-outline": "^1.2.4",
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@vueuse/core": "^13.6.0", "@vueuse/core": "^13.6.0",
"animate.css": "^4.1.1", "axios": "^1.7.9",
"axios": "^0.27.2",
"dayjs": "^1.11.4", "dayjs": "^1.11.4",
"echarts": "^6.0.0",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"lodash-es": "^4.17.21", "naive-ui": "^2.42.0",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1", "pinia-plugin-persistedstate": "^4.4.1",
"sortablejs": "^1.15.6",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-color-kit": "^1.0.6", "vue-router": "^4.5.1"
"vue-draggable-plus": "^0.6.0",
"vue-router": "^4.5.1",
"xe-utils": "^3.7.8"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^7.0.1", "@antfu/eslint-config": "^7.0.1",
"@arco-design/web-vue": "^2.58.0",
"@iconify-json/icon-park-outline": "^1.2.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.2.0", "@types/node": "^24.2.0",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"eslint": "^9.5.0", "eslint": "^9.5.0",
"less": "^4.4.0",
"postcss": "^8.5.6",
"postcss-html": "^1.8.0",
"sass": "^1.62.1", "sass": "^1.62.1",
"stylelint": "^16.25.0",
"stylelint-config-recess-order": "^7.4.0",
"stylelint-config-recommended-vue": "^1.6.1",
"stylelint-config-standard": "^39.0.1",
"stylelint-config-standard-scss": "^16.0.0",
"stylelint-scss": "^6.12.1",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"unplugin-auto-import": "^20.0.0",
"unplugin-icons": "^22.5.0",
"unplugin-vue-components": "^29.0.0",
"vite": "^7.0.6", "vite": "^7.0.6",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^8.0.5",
"vue-tsc": "^3.0.5" "vue-tsc": "^3.0.5"
}, }
"description": "数据信息填报系统 · PC 管理端(管理员 / 班主任)"
} }
+192 -2056
View File
File diff suppressed because it is too large Load Diff
+15 -13
View File
@@ -1,19 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- <!--
浏览器页签图标。 浏览器页签图标。
与 src/assets/images/logo.svg 同一识别符号,但**刻意做减法**: 与 src/assets/images/logo.svg 同一识别符号,但**刻意做减法 + 加粗**:
favicon 实际渲染尺寸只有 16~32px,渐变、高光、内描边到这里全都会糊成噪点, favicon 实际渲染尺寸只有 16~32px,渐变、高光、内描边到这里全都会糊成噪点,
所以只保留「圆角方形 + 填」两个元素 —— 在 1 倍屏下反而是最清楚的。 而 5.9 的白环描边缩到 16px 只剩 1.5px,会被抗锯齿吃掉一半。
配色仍取腾讯云主色 #0052D9。 所以这里:去掉渐变(平铺 #0052D9)、去掉高光与内描边、描边加粗到 7.7。
外接盒与 logo.svg 完全一致(四周留白 8.5),两处切换时不会有位移。
⚠️ index.html 引的是 /favicon.svg。**这里没有 favicon.ico**,
写成 .ico 会 404、页签变成空白(2026-10 修过一次)。
--> -->
<rect x="0" y="0" width="64" height="64" rx="14" fill="#0052D9" /> <rect x="0" y="0" width="64" height="64" rx="15" fill="#0052D9" />
<text
x="32" y="33.5" <!-- 环(在线 / 汇聚点):中线半径 15.4 + 描边 7.7 = 外径 38.5,与 logo.svg 同 -->
fill="#FFFFFF" <circle cx="36.25" cy="26.62" r="15.4"
font-size="40" fill="none" stroke="#FFFFFF" stroke-width="7.7" stroke-opacity="0.82" />
font-weight="700"
font-family="Songti SC, STSong, SimSun, Noto Serif SC, serif" <!-- 方(卷宗) -->
text-anchor="middle" <rect x="8.5" y="22.65" width="34" height="34" rx="10.4" fill="#FFFFFF" />
dominant-baseline="central"
>填</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 763 B

After

Width:  |  Height:  |  Size: 1.2 KiB

+68 -25
View File
@@ -1,31 +1,74 @@
<template>
<a-config-provider update-at-scroll>
<router-view></router-view>
<!-- <template #loading>
<img src="/static/images/loading.gif" class="loading-icon" />
</template> -->
</a-config-provider>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { useTheme } from '@/hooks' import { dateZhCN, NConfigProvider, NDialogProvider, NLoadingBarProvider, NMessageProvider, NNotificationProvider, zhCN } from 'naive-ui'
defineOptions({ name: 'App' }) /**
* 根组件:只做一件事 —— 把 Naive UI 的各种 Provider 装好。
*
* ⚠️ 提示类能力有两套,别混:
* · 组件里用 `useMessage()` / `useDialog()`(从 Provider 注入);
* · 组件之外(http 拦截器、路由守卫)用 `@/utils/naive` 的离屏实例。
* 少了下面这层 Provider,组件里的 `useMessage()` 会直接抛错。
*/
const themeOverrides = {
common: {
// 客户 2026-10-09 明确要求:外壳保持纯白,不要深色菜单/深蓝顶栏
primaryColor: '#0052d9',
primaryColorHover: '#366ef4',
primaryColorPressed: '#003cab',
borderRadius: '4px'
},
const { initThemeColor } = useTheme() /**
initThemeColor() * 卡片式外观。
*
* ⚠️ 描边与阴影**同时**写在 `styles/index.scss` 里(`.n-card.n-card`):
* Naive 的组件样式由 css-render 在运行时注入,与本文件的先后顺序不保证,
* 主题变量负责"正常情况",CSS 那条负责"顺序不利时兜底"。改样式时两处一起改。
*/
Card: {
borderColor: '#e5e8ee',
borderRadius: '10px',
boxShadow: '0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06)',
// 统一左右内边距:Naive 的 small=16 / medium=24 混用会让各页左右不齐
paddingSmall: '12px 18px 12px',
paddingMedium: '16px 18px 16px'
},
// 字典数据:本项目的字典接口属 M1 范围(后端尚未实现), Button: {
// 现在启动就请求会稳定 404 并在控制台刷错误。 // 默认按钮原来用的是 common 的 #e0e0e6 描边 —— 白卡片上几乎看不见,
// 页面用的枚举(组织类型 / 租户状态 / 角色名)目前以常量形式定义在 // 这是"按钮不明显"的主因之一,这里加深一档
// @/apis/xt/* 与 @/config/xt-routes 里,不依赖字典。 border: '1px solid #d0d5dd',
// 待后端 `/system/dict/*` 落地后,把下面两行恢复即可。 borderHover: '1px solid #0052d9',
// const dictStore = useDictStore() borderFocus: '1px solid #0052d9',
// dictStore.getDictData() borderPressed: '1px solid #003cab',
textColor: '#3f4756'
},
DataTable: {
// 表头由 #fafafc 加深到浅灰色带,让"表头 / 表体"和"卡片内容"分得开
thColor: '#f7f8fa',
thTextColor: '#475467',
thFontWeight: '600',
borderColor: '#eef1f5'
},
Tabs: {
// tab 是"当前视图"的切换器,选中态要压得住下面的白底表格
tabFontWeightActive: '600'
}
}
</script> </script>
<style lang="scss" scoped> <template>
.loading-icon { <NConfigProvider :locale="zhCN" :date-locale="dateZhCN" :theme-overrides="themeOverrides">
width: 30px; <NLoadingBarProvider>
} <NDialogProvider>
</style> <NNotificationProvider>
<NMessageProvider>
<router-view />
</NMessageProvider>
</NNotificationProvider>
</NDialogProvider>
</NLoadingBarProvider>
</NConfigProvider>
</template>
-9
View File
@@ -1,9 +0,0 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
/** 获取地区列表 */
export const getProvinceCityArea = (params: { type: 'province' | 'city' | 'area', code?: string }) => {
return http.get<T.AreaItem[]>('/area/getProvinceCityArea', params)
}
-5
View File
@@ -1,5 +0,0 @@
export interface AreaItem {
label: string
code: string
children?: AreaItem[]
}
-39
View File
@@ -1,39 +0,0 @@
import http from '@/utils/http'
interface DefaultP {
GetListParams?: Record<string, any>
GetDetailParams?: { id: string }
AddParams?: Record<string, any>
UpdateParams?: Record<string, any>
DeleteParams?: { ids: string[] }
}
// 获取基础接口
export function getBaseApi<T, P extends DefaultP = DefaultP>(params: { baseUrl: string }) {
const { baseUrl } = params
const baseApi = {
// 获取分页列表
getList(params?: P['GetListParams'] & { page?: number, size?: number }) {
return http.get<PageRes<T[]>>(`${baseUrl}/getList`, params)
},
// 获取详情
getDetail(params: P['GetDetailParams']) {
return http.get<T>(`${baseUrl}/getDetail`, params)
},
// 新增
add(params: P['AddParams']) {
return http.post<T>(`${baseUrl}/add`, params)
},
// 修改
update(params: P['UpdateParams']) {
return http.post<T>(`${baseUrl}/update`, params)
},
// 删除
delete(params: P['DeleteParams']) {
return http.post<boolean>(`${baseUrl}/delete`, params)
}
}
return baseApi
}
-9
View File
@@ -1,9 +0,0 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
/** 获取分类树 */
export function getCateTree() {
return http.get<T.CateTreeItem[]>('/cate/getCateTree')
}
-8
View File
@@ -1,8 +0,0 @@
export interface CateTreeItem {
id: string
name: string
pid: string | null
type: string | null
disabled: boolean
children: CateTreeItem[]
}
-9
View File
@@ -1,9 +0,0 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
/** 获取文件列表 */
export function getFileList(params: { fileType: string | number }) {
return http.get<PageRes<T.FileItem[]>>('/file/getFileList', params)
}
-13
View File
@@ -1,13 +0,0 @@
// 文件
export interface FileItem {
id: string
type: string
name: string
extendName: string
src: string
updateTime: string
isDir: boolean
filePath: string
size: number
[propName: string]: any // 一个 interface 中任意属性只能有一个
}
-7
View File
@@ -1,7 +0,0 @@
import type * as T from './type'
import { getBaseApi } from '@/apis/base'
export type * from './type'
/** 人物模块 */
export const baseAPI = getBaseApi<T.ListItem>({ baseUrl: '/person' })
-16
View File
@@ -1,16 +0,0 @@
// 表格项目
export interface ListItem {
id: string
name: string
account: string
avatar: string
gender: Gender
phone: string
email: string
createTime: string
address: string
age: number
status: Status
hobby: string[]
remark: string
}
-15
View File
@@ -1,15 +0,0 @@
import { getBaseApi } from '@/apis/base'
export interface ListItem {
id: string
name: string
sort: number
status: Status
createTime: string
parentId: string
children?: ListItem[]
description: string
}
/** 部门模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/dept' })
-44
View File
@@ -1,44 +0,0 @@
import type { SelectOptionData } from '@arco-design/web-vue'
import { getBaseApi } from '@/apis/base'
import http from '@/utils/http'
export interface ListItem {
id: string
createUserString: string
createTime: string
name: string
code: string
sort: number
status: Status
description: string
}
export type DictDetail = ListItem
export type DictDataItem = {
id: string
name: string
value: string | number
sort: number
status: Status
remark?: string
createTime?: string
}
/** 字典模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/dict' })
/** 字典数据列表 */
export function getDictDataList(params: { code: string, name?: string, status?: string } & Pagination) {
return http.get<PageRes<DictDataItem[]>>('/system/dict/getDictDataList', params)
}
/** 字典数据详情 */
export function getDictDataDetail(params: { id: string, code: string }) {
return http.get<DictDataItem>('/system/dict/getDictDataDetail', params)
}
/** 获取字典数据映射 */
export function getDictData() {
return http.get<Record<string, SelectOptionData[]>>('/system/dict/getDictData')
}
-39
View File
@@ -1,39 +0,0 @@
import { getBaseApi } from '@/apis/base'
import http from '@/utils/http'
export interface ListItem {
activeMenu: string
alwaysShow: boolean
breadcrumb: boolean
children: ListItem[]
component: string
hidden: boolean
icon: string
id: string
keepAlive: boolean
parentId: string
path: string
permission: string
redirect: string
roles: string[]
showInTabs: boolean
sort: number
status: Status
title: string
type: 1 | 2 | 3
affix: boolean
}
export interface MenuOptionsItem {
id: string
title: string
children: MenuOptionsItem[]
}
/** 菜单模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/menu' })
/** 获取角色分配权限菜单树 */
export function getMenuOptions() {
return http.get<MenuOptionsItem[]>('/system/menu/getMenuOptions')
}
-23
View File
@@ -1,23 +0,0 @@
import { getBaseApi } from '@/apis/base'
import http from '@/utils/http'
export interface ListItem {
id: string
createUserString: string
createTime: string
disabled: boolean
name: string
code: string
sort: number
status: Status
type: number
description: string
}
/** 角色模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/role' })
/** 获取角色权限 */
export function getRoleMenuIds(params: { role: string }) {
return http.get<string[]>('/system/role/getRoleMenuIds', params)
}
-24
View File
@@ -1,24 +0,0 @@
import { getBaseApi } from '@/apis/base'
export interface ListItem {
id: string
createUserString: string
createTime: string
disabled: boolean
deptId: string
deptName: string
username: string
nickname: string
gender: Gender
avatar: string
email: string
phone: string
status: Status
type: 1 | 2
description: string
roleIds: string[]
roleNames: string[]
}
/** 用户模块 */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/user' })
-11
View File
@@ -1,11 +0,0 @@
import http from '@/utils/http'
/** 请求成功的接口 */
export function getTestSuccess() {
return http.get<string>('/test/success')
}
/** 请求失败的接口 */
export function getTestFail() {
return http.get<any>('/test/fail')
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 后端响应契约(《03-接口设计》§1.3)与分页形状。
*
* ⚠️ 成功判定是 `code === 0`,**业务错误也是 HTTP 200**(只有 401/403/429/5xx 用真状态码)。
* 这层适配在 `@/utils/http` 里做掉了:调用方拿到的直接是 `data`,
* 所以业务代码里几乎不再需要碰 `code`。
*/
/** 分页列表(后端恒为 { list, total }) */
export interface PageData<T> {
list: T[]
total: number
}
/** 带业务码的错误(调用方按码分支时用,例如租户重名 40011) */
export interface BizError extends Error {
bizCode?: number
bizData?: unknown
status?: number
}
-24
View File
@@ -1,24 +0,0 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
/** 登录 */
export function login(data: { username: string, password: string }) {
return http.post<T.Login>('/user/login', data)
}
/** 退出登录 */
export function logout() {
return http.post('/user/logout')
}
/** 获取用户信息 */
export const getUserInfo = () => {
return http.get<T.UserInfo>('/user/getUserInfo')
}
/** 获取用户路由信息 */
export const getUserRoutes = () => {
return http.get<T.UserRouteItem[]>('/user/getUserRoutes')
}
-34
View File
@@ -1,34 +0,0 @@
export interface UserInfo {
id: string
nickname: string
avatar: string
roles: string[]
permissions: string[]
}
export interface Login {
token: string
}
export interface UserRouteItem {
activeMenu: string
alwaysShow: boolean
breadcrumb: boolean
children: UserRouteItem[]
component: string
hidden: boolean
icon: string
id: string
keepAlive: boolean
parentId: string
path: string
permission: string
redirect: string
roles: string[]
showInTabs: boolean
sort: number
status: Status
title: string
type: 1 | 2 | 3
affix: boolean
}
+24 -3
View File
@@ -17,11 +17,16 @@
* *
* ## 状态与分级(不要按直觉写分支) * ## 状态与分级(不要按直觉写分支)
* *
* - 填报单状态:`1` 草稿 / `2` 待初审 / `3` 待终审(初审已过)/ `4` 已驳回 / `5` 已通过 * - **填报单状态**(`collect_submission.status`,权威见后端 `CollectSubmission` 的字段注释):
* `0` 草稿 / `1` 待初审 / `2` 待终审 / `3` 已通过 / `4` 已驳回 / `5` 已归档
* - **名册行状态**(`collect_roster.submit_status`)是**另一套枚举**,别混用:
* `0` 未提交 / `1` 草稿 / `2` 待审 / `3` 已通过 / `4` 已驳回(见 `apis/xt/roster.ts`)
* - 卡片分级 `level`:**只有 0 / 2 / 3**,没有 1(写了"信息级"的分支永远不会触发) * - 卡片分级 `level`:**只有 0 / 2 / 3**,没有 1(写了"信息级"的分支永远不会触发)
* *
* ⚠️ 上面这两个数字口径**以服务端返回的 `statusText` 为准**:界面优先显示 `statusText`, * ⚠️ 两套枚举的**起点不同**(填报单从"草稿"起、名册从"未提交"起),
* 数字只用于选色。这样即便枚举有调整,界面也不会说错话。 * 照抄对方必然整体错位一格 —— 本文件曾把填报单写成 `1 草稿 / 2 待初审 / 3 待终审 / 4 已驳回 / 5 已通过`,
* 结果是**状态文案被服务端 `statusText` 兜住了,但 tag 的颜色永远取自错的本地表**
* ("已通过"显示成待终审的蓝)。所以:文案优先用服务端的 `statusText`,本地表只负责选色。
*/ */
import http from '@/utils/http' import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant' import { actingTenantConfig } from './acting-tenant'
@@ -34,6 +39,22 @@ export function bizCodeOf(e: unknown): number | undefined {
return (e as BizError)?.bizCode return (e as BizError)?.bizCode
} }
/**
* 填报单状态 → 文案与色词(`tagOf()` 会把它翻成 `n-tag` 的 type)。
*
* ⚠️ 与 `roster.ts` 的 `SUBMIT_STATUS`(名册行口径)**不是同一套**:起点差一格。
* 改这里之前先看文件头那段说明与后端 `CollectSubmission` 的字段注释。
* 界面文案仍以服务端 `statusText` 为准,本表只负责**选色**与兜底文案。
*/
export const SUBMISSION_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '草稿', color: 'gray' },
1: { text: '待初审', color: 'orange' },
2: { text: '待终审', color: 'arcoblue' },
3: { text: '已通过', color: 'green' },
4: { text: '已驳回', color: 'red' },
5: { text: '已归档', color: 'gray' }
}
// ── 待办列表(§11.1 / §10.2)──────────────────── // ── 待办列表(§11.1 / §10.2)────────────────────
export interface AuditTodo { export interface AuditTodo {
-264
View File
@@ -1,264 +0,0 @@
/**
* 历史数据整理器(《03-接口设计》§13.2)。
*
* ## 这个功能的"卖点"就是报告里的一句话
*
* 契约里写明:**报告里最重要的一句话是 `autoHandled / needManual`** ——
* 「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。
* 所以这一屏的核心不是"进度条",而是**那两个数字与重点清单**。
* 进度只是等待时的安抚,报告才是客户付钱的理由。
*
* ## 几个界面必须区分清楚的状态
*
* | status | 界面该显示什么 |
* |---|---|
* | `0` 排队 / `1` 解析中 / `2` 处理中 | 进度条 + 已处理的文件数;**不给"下载"按钮**(还没有产物) |
* | `3` 已完成 | 报告 + 下载结果包 + 导入为正式任务 |
* | `4` 失败 | **`errorMsg` 必须显示出来** |
*
* ⚠️ 失败时 `errorMsg` 是唯一能让用户知道"为什么"的东西。常见原因是
* "传了不是 zip 的文件"与"包里单个文件超过 50MB" —— 这两条都在服务端有明确文案,
* 界面要做的是**把它原样显示**,而不是笼统说"整理失败"。
*
* ## 为什么 `rosterTaskId` 这个参数要在界面上强调
*
* 契约 §13.2 的原话:**没有名册就只能做文件名规范化;有名单才能做证件号校验、
* 疑似串档检测** —— 而"疑似串档 1 例"正是报告里最有价值的一条。
* 也就是说:关联名册与不关联,"体检报告"是两份完全不同的东西。
* 界面必须把这件事说在**上传之前**,而不是等报告出来发现那两项全是 0。
*/
import { getToken } from '@/utils/auth'
import http from '@/utils/http'
/** 整理任务状态。 */
export type CleanupStatus = 0 | 1 | 2 | 3 | 4
export interface CleanupJobVO {
id: number
jobName: string
/** `1` 压缩包 / `2` 文件夹 / `3` 云盘链接(一期只支持 `1`)。 */
sourceType: number
/** 关联的已有名册任务。**有关联才有证件号校验与串档检测**。 */
rosterTaskId?: number | null
status: CleanupStatus
statusText: string
progress: number
totalFiles?: number | null
processedFiles?: number | null
parsedStudents?: number | null
parsedMaterials?: number | null
rawSizeBytes?: number | null
finalSizeBytes?: number | null
/** 形如 `91.5%`;没算出来时是 `—`(**不是 0%** —— 0% 看起来像"没压缩")。 */
compressRatio?: string
resultPath?: string | null
/** 导入成正式任务后的任务 id(有值就说明已导入,导入是幂等的)。 */
targetTaskId?: number | null
errorMsg?: string | null
createTime?: string
finishTime?: string | null
}
export interface CleanupJobPage {
list: CleanupJobVO[]
total: number
}
/** 报告汇总。 */
export interface ReportSummary {
totalFiles: number
nameInvalid: number
sizeExceeded: number
formatConverted: number
idcardMismatch: number
idcardAutoFixed: number
idcardNeedConfirm: number
/** 疑似串档:某人的**多项**材料证件号与名册均不同、且彼此一致 → 整个文件夹可能被换过。 */
crossFileSuspected: number
lowResolution: number
orphanImages: number
}
export interface Highlight {
type: string
/** `1` 提示 / `2` 警告 / `3` 必须人工。 */
level: number
rosterId?: number | null
realName?: string | null
title: string
materialCode?: string | null
detail?: string | null
suggestion?: string | null
}
export interface NameFixSample {
original: string
fixed: string
rule?: string
}
export interface IdcardConflict {
rosterId?: number | null
realName?: string | null
rosterValue?: string | null
fileNameValue?: string | null
verdict: string
verdictText: string
}
export interface CleanupReportVO {
jobId: number
summary: ReportSummary
/** 系统全自动处理了多少(**服务端算的**,不让前端去减)。 */
autoHandled: number
/** 必须人工判断的(串档 + 无法归位 + 待确认的证件号冲突)。 */
needManual: number
highlights: Highlight[]
nameFixedSamples: NameFixSample[]
idcardConflicts: IdcardConflict[]
}
export interface CleanupImportVO {
taskId: number
rosterCount: number
fileCount: number
}
export function getCleanupJobs(params: {
status?: number
keyword?: string
pageNum?: number
pageSize?: number
}) {
return http.get<CleanupJobPage>('/cleanup/jobs', params)
}
export function getCleanupJob(id: number) {
return http.get<CleanupJobVO>(`/cleanup/jobs/${id}`)
}
export function getCleanupReport(id: number) {
return http.get<CleanupReportVO>(`/cleanup/jobs/${id}/report`)
}
/**
* 创建整理任务(multipart)。
*
* ⚠️ 超时给到 600s:大包的解压 + 解析 + 重新打包都在服务端同步做,
* 而默认 30s 在几百 MB 的包上必然超时 —— 超时的表现是"点了没反应",
* 用户会重复点,于是同一个包被整理好几次。
*/
export function createCleanupJob(file: File, opts: {
jobName: string
rosterTaskId?: number
namingTemplate?: string
}) {
const fd = new FormData()
fd.append('file', file)
fd.append('jobName', opts.jobName)
fd.append('sourceType', '1')
if (opts.rosterTaskId !== undefined) {
fd.append('rosterTaskId', String(opts.rosterTaskId))
}
if (opts.namingTemplate) {
fd.append('namingTemplate', opts.namingTemplate)
}
// 不要手动设 Content-Type:axios 会带着 boundary 自己设
return http.request<{ jobId: number, status: number, estimatedSeconds: number }>({
method: 'post',
url: '/cleanup/jobs',
data: fd,
timeout: 600 * 1000
})
}
/**
* 导入为正式任务。
*
* ⚠️ 这条路**不能带 `importFiles`**:一期还做不到把整理结果里的文件自动挂到填报单,
* 而服务端会**如实拒绝**(`40044`)而不是安静忽略。界面因此不要给这个勾选项 ——
* 给了一个必然报错的选项,用户只会以为系统坏了。
*/
export function importCleanupJob(id: number, data: {
taskName: string
collectMode?: number
batchNo: string
importRoster?: boolean
majorName?: string
}) {
return http.post<CleanupImportVO>(`/cleanup/jobs/${id}/import`, {
collectMode: 1,
importRoster: true,
...data
})
}
/**
* 真正下载整理结果包。
*
* ⚠️ 必须带 token 取 blob 再本地触发下载:`/cleanup/jobs/{id}/result` 是要登录的,
* 直接 `window.open` / `<a href>` 都不会带 `Authorization` 头,
* 用户会看到一个 401 页面(或"下载没反应")。
*
* 放在 api 层而不是页面里,与导出中心的 `downloadExportFile` 保持同一写法 ——
* 同一件事在一个项目里有两处实现,改的时候一定会漏一处。
*/
export async function downloadCleanupResult(id: number, jobName?: string): Promise<void> {
const token = getToken()
const base = import.meta.env.VITE_API_PREFIX || '/api/v1'
const res = await fetch(`${base}/cleanup/jobs/${id}/result`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
if (!res.ok) {
throw new Error(`下载失败(HTTP ${res.status})`)
}
const blob = await res.blob()
const href = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = href
a.download = `${jobName || '整理结果'}-${id}.zip`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(href)
}
/** 状态 → Tag 颜色(Arco 的 status 只认 normal/warning/success/danger)。 */
export function statusColor(status: CleanupStatus): 'normal' | 'warning' | 'success' | 'danger' {
switch (status) {
case 3:
return 'success'
case 4:
return 'danger'
case 1:
case 2:
return 'warning'
default:
return 'normal'
}
}
/** 重点条目的级别 → Tag 颜色。`3` 是"必须人工",用红色。 */
export function levelColor(level: number): 'normal' | 'warning' | 'danger' {
if (level >= 3) return 'danger'
if (level === 2) return 'warning'
return 'normal'
}
/**
* 报告里"最有说服力的一句话"。
*
* 契约的原话就是这个格式:**「112 个文件里 90 个系统全自动处理,
* 只有 1 个必须你亲自判断」**。所以这句话由前端按同一口径拼 ——
* 但它拼的是**服务端算好的两个数**,不是自己减出来的
* (前端减出来的数字早晚会与服务端分叉)。
*/
export function headlineOf(report: CleanupReportVO): string {
const total = report.summary.totalFiles
const need = report.needManual
return `${total} 个文件里,${report.autoHandled} 个系统全自动处理,`
// ⚠️ 不要在这里写 Markdown 的 `**粗体**`:它落在**文本节点**里,
// 页面上会原样显示两个星号(要粗体得让调用方用富文本渲染)
+ `${need > 0 ? `只有 ${need} 个必须你亲自判断` : '没有需要你判断的'}`
}
+2 -2
View File
@@ -191,7 +191,7 @@ export async function downloadExportFile(id: number, fileIndex = 0): Promise<voi
const base = import.meta.env.VITE_API_PREFIX || '/api/v1' const base = import.meta.env.VITE_API_PREFIX || '/api/v1'
// 服务端回的是本服务的绝对路径(/api/v1/exports/{id}/file?...), // 服务端回的是本服务的绝对路径(/api/v1/exports/{id}/file?...),
// 去掉前缀再拼 base,避免出现 /api/v1/api/v1/... 这种双前缀 // 去掉前缀再拼 base,避免出现 /api/v1/api/v1/... 这种双前缀
const res = await fetch(`${base}${info.data.url.replace('/api/v1', '')}`, { const res = await fetch(`${base}${info.url.replace('/api/v1', '')}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {} headers: token ? { Authorization: `Bearer ${token}` } : {}
}) })
if (!res.ok) { if (!res.ok) {
@@ -201,7 +201,7 @@ export async function downloadExportFile(id: number, fileIndex = 0): Promise<voi
const href = URL.createObjectURL(blob) const href = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = href a.href = href
a.download = info.data.fileName a.download = info.fileName
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
a.remove() a.remove()
-125
View File
@@ -1,125 +0,0 @@
/**
* 名单加入申请接口(《03-接口设计》§8.7.3 ~ §8.7.5,FR-509)。
*
* ## 这条通道的边界(界面文案要照着说)
*
* 它**只解决"进名单",不绕过门禁** —— 申请期间学生仍然不能填报。
* 所以审批通过之后,界面要告诉管理员"学生返回重新核验就能进",
* 而不是"他已经可以填了"。
*
* ## 两个刻意的设计,前端要照做
*
* 1. **没有批量通过/一键全通过接口**(契约 §8.7.5 的取舍):身份认定环节的
* 误操作代价极高 —— 放错一个人进名单,等于让一个不是这批的人拿到认定资格。
* 所以这里只有逐条通过、逐条驳回(驳回还必须填原因)。
* 2. **`notifySent` 恒为 false**:通知渠道(M5-12)还没做,
* 服务端如实返回 false,界面必须显示"请口头/群里告知学生",不能假装通知过了。
*/
import http from '@/utils/http'
export interface JoinDupHint {
type: string
taskName: string
realName: string
}
export interface JoinAdminItem {
requestId: number
taskId: number
taskName: string | null
realName: string
idCardMask: string
phoneMask: string | null
orgHint: string | null
reason: string | null
/** `0` 待审 / `1` 已通过 / `2` 已驳回 */
status: number
statusText: string
createTime: string | null
waitHours: number | null
/** 该证件号在**其他任务**的名册里出现过(帮判断是不是本校学生) */
duplicateHint: JoinDupHint | null
rosterId: number | null
}
export interface JoinAdminList {
list: JoinAdminItem[]
total: number
counts: { pending: number, approved: number, rejected: number }
alert: { level: number, text: string } | null
}
export interface JoinListQuery {
taskId?: number
/** 默认 0(待审) */
status?: number
keyword?: string
pageNum?: number
pageSize?: number
}
export function getJoinRequests(params: JoinListQuery = {}) {
return http.get<JoinAdminList>('/roster/join-requests', params)
}
export interface JoinApproveReq {
/** 指定班级;不传则用学生的自述班级匹配组织名(匹配不上会要求手选) */
orgId: number
/** 修正姓名(管理员核对后可改) */
realName?: string
/** 修正证件号 */
idCardNo?: string
}
export interface JoinApproveResult {
requestId: number
status: number
rosterId: number
rosterCreated: boolean
/** ⚠️ 恒为 false:通知没发出去,需要人工告知 */
notifySent: boolean
/** true = 已绑定,学生返回重新核验就能直接进 */
canFillNow: boolean
}
export function approveJoinRequest(id: number, data: JoinApproveReq) {
return http.post<JoinApproveResult>(`/roster/join-requests/${id}/approve`, data)
}
export interface JoinRejectReq {
/** **必填** —— 会原样给学生看 */
reason: string
reasonCode?: string
}
export function rejectJoinRequest(id: number, data: JoinRejectReq) {
return http.post<{ requestId: number, status: number, notifySent: boolean }>(
`/roster/join-requests/${id}/reject`,
data
)
}
/** 申请状态。 */
export const JOIN_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '待审核', color: 'orange' },
1: { text: '已通过', color: 'green' },
2: { text: '已驳回', color: 'red' }
}
/** 驳回快捷原因码(契约 §8.7.5)。 */
export const REJECT_REASON_CODES = [
{ code: 'NOT_THIS_SCHOOL', label: '不是本校学生' },
{ code: 'INFO_MISMATCH', label: '信息与报名资料不符' },
{ code: 'NOT_IN_SCOPE', label: '不在本次认定范围' },
{ code: 'OTHER', label: '其他原因(请在说明里写清)' }
]
/** 关键错误码 → 一句"接下来怎么办"。 */
export const JOIN_ERROR_HINT: Record<number, string> = {
40040: '这条申请已经被别人处理过了(刷新列表即可看到最新状态)。',
40041: '名册里已经有这个证件号了 —— 可能刚被老师手工加进去,去「名单管理」核对一下。',
40042: '已通过的申请不能驳回。要撤销请到「名单管理」里删掉那条名册项。',
40037: '该学生申请次数已达上限(每任务 2 次)。',
40038: '该学生已有一条待审申请。',
40039: '该证件号已在名册中 —— 学生只是姓名打错了,让他回去重新核验即可。'
}
+8 -2
View File
@@ -106,8 +106,14 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
{ value: 'PROXY_UPLOAD', label: '代传材料' }, { value: 'PROXY_UPLOAD', label: '代传材料' },
{ value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' }, { value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' },
{ value: 'CONFIG_COMPRESS', label: '改压缩配置' }, { value: 'CONFIG_COMPRESS', label: '改压缩配置' },
{ value: 'JOIN_APPROVE', label: '通过加入申请' }, // ⚠️ 2026-10 需求收敛:`JOIN_APPROVE` / `JOIN_REJECT` 两个动作码已随「加入申请」模块删除,
{ value: 'JOIN_REJECT', label: '驳回加入申请' } // 这里不再列出(库里若有历史行,`actionText` 会原样回显那个码,不会显示成空白)。
{ value: 'CREATE_ROLE', label: '新建角色' },
{ value: 'UPDATE_ROLE', label: '改角色' },
{ value: 'DELETE_ROLE', label: '删除角色' },
{ value: 'GRANT_ROLE', label: '改角色授权' },
{ value: 'UPDATE_ROLE_MENU', label: '改角色菜单' },
{ value: 'TOGGLE_ROLE', label: '停用/启用角色' }
] ]
export function actionText(v?: string) { export function actionText(v?: string) {
+4 -2
View File
@@ -99,8 +99,10 @@ export const BIZ_TYPES: Array<{ value: number, label: string }> = [
{ value: 3, label: '驳回通知' }, { value: 3, label: '驳回通知' },
{ value: 4, label: '审核通过' }, { value: 4, label: '审核通过' },
{ value: 5, label: '催报' }, { value: 5, label: '催报' },
{ value: 6, label: '导出完成' }, { value: 6, label: '导出完成' }
{ value: 7, label: '加入审批' } // ⚠️ 2026-10 需求收敛:`7 加入审批` 已随「加入申请」模块删除。
// 后端 `CollectMessage.BIZ_JOIN_AUDIT` 也删了 —— 这里留着它会让运营
// 在下拉里选一个永远查不到记录的类型。
] ]
export function bizTypeText(v?: string | number) { export function bizTypeText(v?: string | number) {
+5 -2
View File
@@ -34,8 +34,11 @@ export interface OrgUpsertReq {
/** /**
* 组织树。 * 组织树。
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开) *
* @param tenantId **仅平台运营**:代管哪个学校的组织(见 `acting-tenant.ts`)。 * @param params 查询条件(下面三个字段都可选;整体可省略)
* @param params.withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
* @param params.rootId 只取某个节点为根的子树
* @param params.tenantId **仅平台运营**:代管哪个学校的组织(见 `acting-tenant.ts`)。
* 不传 = 不代管 —— 此时服务端只会返回平台自己租户的组织(空树), * 不传 = 不代管 —— 此时服务端只会返回平台自己租户的组织(空树),
* **不会**再把所有租户的组织混在一起端出来。 * **不会**再把所有租户的组织混在一起端出来。
*/ */
+183
View File
@@ -0,0 +1,183 @@
/**
* 角色与授权(二期)。
*
* ## 三条口径(界面上要按它写,不要"看起来更合理"地改)
*
* 1. **内置角色只读**:`roleType === 1` 的角色可以看权限矩阵,但改不了 ——
* 它的权限由后端代码决定(`BuiltinRole`)。一次误操作就能把管理员锁在系统外,
* 而这类错误不可逆。所以界面上的正确做法是**新建一个自定义角色**,而不是改内置的。
* 2. **看得见 ≠ 有权限**:`menus` 只决定左侧出现哪些入口;真正的访问控制是
* 后端对权限码的校验(绕过前端只会拿到 `40301`)。
* 3. **数据范围(本人/本班/本院系/全租户)本期只展示**:它目前由"拿到了哪些权限码 +
* 任务里被指派了哪些班"共同决定,做成可配要先把散在各处的判据收口(下一批)。
* 所以这里的 `dataScope` 是只读展示 —— 让界面出现一个"改了不生效"的开关更糟。
*
* ## 改完立即生效
*
* 服务端在每次写授权后会清掉该角色的缓存,所以**不需要重新登录**:
* 改完刷新页面即可看到按钮/菜单的变化。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 角色(内置或自定义) */
export interface RoleVO {
id: number
roleCode: string
roleName: string
/** 1 内置(只读)/ 2 自定义 */
roleType: number
/** 1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生 —— 账号的用户类型由它推导 */
userType: number
/** 1 全平台 / 2 本租户 / 3 本组织及下级 / 4 仅本人(本期只展示) */
dataScope: number
/** 0 停用 / 1 正常 */
status: number
sortNo?: number
remark?: string | null
/** 有多少账号在用(删除前的依据:大于 0 就删不掉) */
userCount: number
/** 能否编辑 / 删除 / 停用(内置角色恒 false) */
editable: boolean
/**
* 能否发给账号。
*
* ⚠️ 与 `editable` 是两件事:内置的「平台运营」角色**看得见**(列表里列出来),
* 但在学校租户里**发不出去**(服务端 40301 —— 拿到它会直接越权到平台级)。
* 所以账号页的角色选项要按它过滤,而不是让人选完再收到一个错误。
*/
assignable: boolean
permCodes: string[]
menus: string[]
}
/** 权限码(角色授权界面的勾选项) */
export interface PermVO {
code: string
label: string
/** PC / MP / BOTH */
platform: string
/** true = 平台级权限,学校租户不可授予(界面上要禁用勾选) */
platformOnly: boolean
}
/** 权限码按分组返回(30 条平铺成一张表没人看得懂) */
export interface PermGroupVO {
group: string
items: PermVO[]
}
export interface RoleCreateReq {
roleCode: string
roleName: string
userType: number
remark?: string
permCodes: string[]
menus: string[]
}
export interface RoleUpdateReq {
roleName: string
userType: number
remark?: string
}
/** 账号页用的角色选项(内置 + 自定义都在这份里) */
export interface RoleOption {
value: string
label: string
userType: number
roleType: number
status: number
}
export function getRoles(tenantId?: number | null) {
return http.get<RoleVO[]>('/roles', undefined, actingTenantConfig(tenantId))
}
export function getPermissions() {
return http.get<PermGroupVO[]>('/permissions')
}
export function createRole(data: RoleCreateReq) {
return http.post<number>('/roles', data)
}
export function updateRole(id: number, data: RoleUpdateReq) {
return http.put<void>(`/roles/${id}`, data)
}
export function deleteRole(id: number) {
return http.delete<void>(`/roles/${id}`)
}
/** 覆盖式设置权限码(改完立即生效,不必重新登录) */
export function setRolePermissions(id: number, permCodes: string[]) {
return http.put<void>(`/roles/${id}/permissions`, { permCodes })
}
/** 覆盖式设置可见菜单 */
export function setRoleMenus(id: number, menus: string[]) {
return http.put<void>(`/roles/${id}/menus`, { menus })
}
export function setRoleStatus(id: number, status: number) {
return http.put<void>(`/roles/${id}/status`, undefined, { params: { status } })
}
/** 角色列表 → 账号页的下拉/勾选项(只取**能发给账号**的启用角色) */
export async function getRoleOptions(tenantId?: number | null): Promise<RoleOption[]> {
const res = await getRoles(tenantId)
return (res ?? [])
.filter((r) => r.status === 1 && r.assignable !== false)
.map((r) => ({
value: r.roleCode,
label: r.roleName,
userType: r.userType,
roleType: r.roleType,
status: r.status
}))
}
/**
* 从所选角色推导用户类型。
*
* 用户类型与角色是**一对一绑定**的(账号的 `userType` 由它决定),所以界面上不让用户手选:
* 分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,而服务端还会因此拒掉整个提交。
*
* ⚠️ 判据来自**接口返回的角色**(`RoleVO.userType`),不是前端写死的映射表 ——
* 二期之后可选角色里还有租户自己建的那些,写死的表推不出它们的类型。
*
* @returns 同一类时返回那个类型;跨类(如「班主任 + 审核员」)或选不出时返回 null
*/
export function deriveUserType(roleCodes: string[], options: RoleOption[]): number | null {
const types = new Set<number>()
for (const code of roleCodes) {
const opt = options.find((o) => o.value === code)
if (opt) {
types.add(opt.userType)
}
}
return types.size === 1 ? [...types][0] : null
}
/** 用户类型中文名(与契约 §2.7 / 后端 BuiltinRole.UserType 一致) */
export const ROLE_USER_TYPE_NAMES: Record<number, string> = {
1: '平台运营',
2: '学校管理员',
3: '班主任',
4: '学生'
}
/** 数据范围中文名(本期只展示) */
export const DATA_SCOPE_NAMES: Record<number, string> = {
1: '全平台',
2: '本租户',
3: '本组织及下级',
4: '仅本人'
}
/** 错误码 → 人话(后端 msg 已经说清了,这里只补"我该做什么") */
export const ROLE_ERROR_HINT: Record<number, string> = {
40045: '该角色还有账号在用:请先给这些账号换一个角色,再回来删'
}
+14 -16
View File
@@ -121,6 +121,7 @@ export interface TaskListQuery {
/** /**
* 任务分页列表。班主任只会拿到自己带的班级的任务 * 任务分页列表。班主任只会拿到自己带的班级的任务
* *
* @param params 分页与筛选(`orgId` / `status` / `keyword` / `pageNum` / `pageSize`)
* @param tenantId **仅平台运营**:看哪所学校的任务(见 `./acting-tenant`)。 * @param tenantId **仅平台运营**:看哪所学校的任务(见 `./acting-tenant`)。
* 不传 = 平台自己租户(空列表)—— 不再把各租户的任务混在一起。 * 不传 = 平台自己租户(空列表)—— 不再把各租户的任务混在一起。
*/ */
@@ -254,8 +255,6 @@ export interface TaskOrgVO {
*/ */
headUserId: number | null headUserId: number | null
headUserName: string | null headUserName: string | null
deputyUserIds: number[]
deputyUserNames: string[]
/** 应填人数(期望值)。null = 未设置,与 0(预期 0 人)含义不同 */ /** 应填人数(期望值)。null = 未设置,与 0(预期 0 人)含义不同 */
expectedCount: number | null expectedCount: number | null
/** 恒为 1(只返回生效行) */ /** 恒为 1(只返回生效行) */
@@ -266,7 +265,6 @@ export interface TaskOrgVO {
export interface TaskOrgItem { export interface TaskOrgItem {
orgId: number orgId: number
headUserId?: number | null headUserId?: number | null
deputyUserIds?: number[]
/** 不传或传 null 表示"未设置",**不要传 0** —— 那是"预期 0 人" */ /** 不传或传 null 表示"未设置",**不要传 0** —— 那是"预期 0 人" */
expectedCount?: number | null expectedCount?: number | null
} }
@@ -281,23 +279,23 @@ export function saveTaskOrgs(taskId: number, orgs: TaskOrgItem[]) {
} }
/** /**
* 可被指定为初审人的账号。 * 可被指定为初审人的账号(只能是**班主任**)。
* *
* 拉两种角色再合并:后端要求初审人持有班主任**或**副班主任角色, * ⚠️ 2026-10 需求收敛:删掉副班主任角色后,初审人只剩班主任一种。
* 所以两边的账号都是合法选项。只取启用状态 —— 停用的账号会被后端拒掉, * 这里**不能**再顺手带一个 `roleCode=DEPUTY_TEACHER` 的查询 ——
* 列在选项里只会让人白选一次。 * 那个角色码已经不在后端的 `BuiltinRole` 里,而老版本的后端对
* "不认识的角色码"是**静默忽略过滤条件**(等于返回全校账号,含学生)。
* 后端已改成"失败关闭"(返回空列表),前端这边也不再传它。
*
* 只取启用状态 —— 停用的账号会被后端拒掉,列在选项里只会让人白选一次。
*/ */
export async function getTeacherOptions(): Promise<Array<{ id: number, label: string }>> { export async function getTeacherOptions(): Promise<Array<{ id: number, label: string }>> {
const [head, deputy] = await Promise.all([ const page = await getUserList({ roleCode: 'HEAD_TEACHER', status: 1, pageSize: 200 })
getUserList({ roleCode: 'HEAD_TEACHER', status: 1, pageSize: 200 }), return (page.list ?? []).map((u) => ({
getUserList({ roleCode: 'DEPUTY_TEACHER', status: 1, pageSize: 200 }) id: u.id,
])
const merged = new Map<number, string>()
for (const u of [...(head.data?.list ?? []), ...(deputy.data?.list ?? [])]) {
// 带账号:学校里同名同姓很常见,只显示姓名选错了就是把别人的班交给了错误的人 // 带账号:学校里同名同姓很常见,只显示姓名选错了就是把别人的班交给了错误的人
merged.set(u.id, u.username ? `${u.realName}(${u.username})` : u.realName) label: u.username ? `${u.realName}(${u.username})` : u.realName
} }))
return Array.from(merged.entries(), ([id, label]) => ({ id, label }))
} }
// ── 任务统计(§7.11,工作台看板用)───────────────── // ── 任务统计(§7.11,工作台看板用)─────────────────
+9 -57
View File
@@ -152,30 +152,17 @@ export const USER_TYPE_NAMES: Record<number, string> = {
} }
/** /**
* 角色 → 用户类型。 * 角色选项、角色 → 用户类型、角色名的映射**都不在这里了**。
* *
* 二者本来就是一对一绑定的,所以: * 二期(角色与授权)之后,可选角色不再只有 6 个内置角色 —— 租户自己建的角色也要能发出去,
* · 表单里**不让用户选**用户类型,而是从角色推导出来(避免选出矛盾组合) * 而它们的名字与用户类型只存在于库里。所以这三件事统一走 `@/apis/xt/role`:
* · 服务端也会用同一套映射反推校验(`UserService.assertRoleMatchesUserType`) * · 选项:`getRoleOptions()`(`GET /roles`)
* · 用户类型:`RoleVO.userType`(`deriveUserType(roleCodes, options)`)
* · 角色名:`UserVO.roleNames`(后端已按 `sys_role.role_name` 装配)
*
* ⚠️ 留一份前端写死的映射表会**静默过期**:自定义角色推不出类型,
* 表现是"账号建不出来,但看不出为什么"(服务端 40001:用户类型与所选角色不符)。
*/ */
export const ROLE_TO_USER_TYPE: Record<string, number> = {
PLATFORM_ADMIN: 1,
SCHOOL_ADMIN: 2,
AUDITOR: 2,
HEAD_TEACHER: 3,
DEPUTY_TEACHER: 3,
STUDENT: 4
}
/** 从所选角色推导用户类型;角色为空或跨类时返回 `null`(调用方据此拦提交) */
export function deriveUserType(roleCodes: string[]): number | null {
const types = new Set<number>()
for (const c of roleCodes) {
const t = ROLE_TO_USER_TYPE[c]
if (t) types.add(t)
}
return types.size === 1 ? [...types][0] : null
}
/** 账号状态(对应 sys_user.status) */ /** 账号状态(对应 sys_user.status) */
export const USER_STATUS: Record<number, { text: string, color: string }> = { export const USER_STATUS: Record<number, { text: string, color: string }> = {
@@ -184,41 +171,6 @@ export const USER_STATUS: Record<number, { text: string, color: string }> = {
2: { text: '待激活', color: 'orange' } 2: { text: '待激活', color: 'orange' }
} }
/**
* 可分配的角色,**必须与后端 `BuiltinRole` 逐字一致**。
*
* 为什么在前端也写一份:一期没有任何"自定义角色"的界面,角色集合由代码决定、
* 不由数据库决定(这是项目已定的决策 —— 权限码是前后端契约,放 DB 必然漂移)。
* 所以这里是「与后端枚举对称的镜像」,不是"业务配置"。
*
* 真实的可分配范围仍由后端 `UserService.resolveAssignableRoles` 判定:
* 传了这里的列表之外的角色会拿到 40301,前端只负责不显示没得选的项。
*
* 平台运营角色只在平台租户可分配 —— 学校管理员拿到它会直接越权到平台级。
*/
export const SCHOOL_ROLE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'SCHOOL_ADMIN', label: '学校管理员' },
{ value: 'HEAD_TEACHER', label: '班主任' },
{ value: 'DEPUTY_TEACHER', label: '副班主任' },
{ value: 'AUDITOR', label: '审核员' },
{ value: 'STUDENT', label: '学生' }
]
/** 平台租户的可分配角色 */
export const PLATFORM_ROLE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'PLATFORM_ADMIN', label: '平台运营' }
]
/** 导入时「角色」列允许写的角色中文名 → 角色码(与后端 BuiltinRole 对齐) */
export const ROLE_NAME_TO_CODE: Record<string, string> = {
平台运营: 'PLATFORM_ADMIN',
学校管理员: 'SCHOOL_ADMIN',
班主任: 'HEAD_TEACHER',
副班主任: 'DEPUTY_TEACHER',
审核员: 'AUDITOR',
学生: 'STUDENT'
}
/** 导入文件里被识别的列名(与后端 HEADER_ALIASES 对齐,供界面提示用) */ /** 导入文件里被识别的列名(与后端 HEADER_ALIASES 对齐,供界面提示用) */
export const IMPORT_FIELD_NAMES: Record<string, string> = { export const IMPORT_FIELD_NAMES: Record<string, string> = {
realName: '姓名', realName: '姓名',
+31 -21
View File
@@ -1,15 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="填报通"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="深学在线">
<!-- <!--
填报通 品牌标 深学在线 品牌标 · 2026-10 定稿
================================================================ ================================================================
配色取自腾讯云 / TDesign 官方色板(与 src/styles/arco-ui/xt-theme.less 一致): 替代原先「圆角方块 + 填字」的文字标。两个几何体,都有出处:
主色 #0052D9 主色亮阶 #2975E1 浅底 #ECF2FE · 实心圆角方 = 卷宗(原型设计语言「档案室 / 电子卷宗」)
· 白 环 = 在线 / 汇聚点(这件产品的入口就是扫码进来)
环画在方**之前**,只有右上四分之一露出来 —— 一个标里同时读到"在线"与"归档"。
设计说明: 为什么是几何标而不是字形标:字形标在 16px 页签下退化成一块色斑,
· 圆角方形 + 「填」字,与产品原型(docs/prototype 的 .mark)保持同一识别符号 换字体族还会走形。为什么后排是环而不是第二枚圆角方:两枚错位圆角方
· 用渐变而不是纯色,是为了在 32px 的侧栏尺寸下也不显得扁平死板 是通用的「复制 / 多副本」图标,会被直接读成那个意思;环与方是相叠的
· 顶部一道极淡的高光,给方形一点体积感(模仿纸质卡片的受光) 一体,也不会被读成放大镜。完整取舍过程见 docs/交接摘要.md §一·三十八,
· 不画对勾等附加符号:尺寸一旦缩到 favicon,多余元素只会变成噪点 五轮实测稿在 shots/logo-lab*.html。
构造(对齐 64 栅格;四周留白对称,均为 8.5;外接盒 47×49.3 居中):
· 圆角方 34×34 @ (8.5, 22.65) 圆角 10.4 —— 坐下偏左
· 白环 外径 38.5(描边 5.9 / 中线半径 16.31)@ (36.25, 26.62) —— 立上偏右
⚠️ 留白从 11.25 收到 8.5 是**有意**的:11.25 时内容只占底板 65%,
24px 侧栏下真正画出来的只有 16px,标明显偏小(见 shots/rename/logo-sizes.png)。
⚠️ 改这里要同步三处:public/favicon.svg(加粗版)、AppSider 与 login 页的
引用尺寸(24 / 34px)、docs/prototype/admin-web.html 的 .mark(内嵌同形状)。
⚠️ 注释里不能出现连续两个减号(XML 规范禁止,非浏览器宽容度问题):
写下 CSS 自定义属性的那种两减号写法会让整个 SVG not well-formed,
表现是图片裂开、控制台一句话都不报,lint / typecheck 也全绿。
--> -->
<defs> <defs>
<linearGradient id="xtLogoBg" x1="0" y1="0" x2="0.35" y2="1"> <linearGradient id="xtLogoBg" x1="0" y1="0" x2="0.35" y2="1">
@@ -22,24 +36,20 @@
</linearGradient> </linearGradient>
</defs> </defs>
<!-- 底板 --> <!-- 底板(配色取腾讯云 / TDesign 色板,与主题主色一致) -->
<rect x="0" y="0" width="64" height="64" rx="15" fill="url(#xtLogoBg)" /> <rect x="0" y="0" width="64" height="64" rx="15" fill="url(#xtLogoBg)" />
<!-- 顶部高光 --> <!-- 顶部高光:给方形一点体积感(模仿纸质卡片的受光) -->
<path d="M0 15A15 15 0 0 1 15 0h34a15 15 0 0 1 15 15v9H0z" fill="url(#xtLogoGloss)" /> <path d="M0 15A15 15 0 0 1 15 0h34a15 15 0 0 1 15 15v9H0z" fill="url(#xtLogoGloss)" />
<!-- 内描边:让标在浅色底上也有轮廓,不至于"浮"着 --> <!-- 内描边:让标在浅色底上也有轮廓,不至于"浮"着 -->
<rect x="0.75" y="0.75" width="62.5" height="62.5" rx="14.25" <rect x="0.75" y="0.75" width="62.5" height="62.5" rx="14.25"
fill="none" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="1.5" /> fill="none" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="1.5" />
<!-- 「填」字 --> <!-- 环(在线 / 汇聚点)—— 画在方之前,被方遮掉左下四分之三 -->
<text <circle cx="36.25" cy="26.62" r="16.31"
x="32" y="33.5" fill="none" stroke="#FFFFFF" stroke-width="5.9" stroke-opacity="0.76" />
fill="#FFFFFF"
font-size="37" <!-- 方(卷宗) -->
font-weight="600" <rect x="8.5" y="22.65" width="34" height="34" rx="10.4" fill="#FFFFFF" />
font-family="Songti SC, STSong, SimSun, Noto Serif SC, Source Han Serif SC, serif"
text-anchor="middle"
dominant-baseline="central"
>填</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

-78
View File
@@ -1,78 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
-188
View File
@@ -1,188 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import { GlobalComponents } from 'vue'
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AAlert: typeof import('@arco-design/web-vue')['Alert']
AAvatar: typeof import('@arco-design/web-vue')['Avatar']
ABadge: typeof import('@arco-design/web-vue')['Badge']
ABreadcrumb: typeof import('@arco-design/web-vue')['Breadcrumb']
ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem']
AButton: typeof import('@arco-design/web-vue')['Button']
ACard: typeof import('@arco-design/web-vue')['Card']
ACheckbox: typeof import('@arco-design/web-vue')['Checkbox']
ACheckboxGroup: typeof import('@arco-design/web-vue')['CheckboxGroup']
ACol: typeof import('@arco-design/web-vue')['Col']
AComment: typeof import('@arco-design/web-vue')['Comment']
AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider']
ADatePicker: typeof import('@arco-design/web-vue')['DatePicker']
ADescriptions: typeof import('@arco-design/web-vue')['Descriptions']
ADescriptionsItem: typeof import('@arco-design/web-vue')['DescriptionsItem']
ADivider: typeof import('@arco-design/web-vue')['Divider']
ADoption: typeof import('@arco-design/web-vue')['Doption']
ADrawer: typeof import('@arco-design/web-vue')['Drawer']
ADropdown: typeof import('@arco-design/web-vue')['Dropdown']
AEmpty: typeof import('@arco-design/web-vue')['Empty']
AForm: typeof import('@arco-design/web-vue')['Form']
AFormItem: typeof import('@arco-design/web-vue')['FormItem']
AGrid: typeof import('@arco-design/web-vue')['Grid']
AGridItem: typeof import('@arco-design/web-vue')['GridItem']
AInput: typeof import('@arco-design/web-vue')['Input']
AInputGroup: typeof import('@arco-design/web-vue')['InputGroup']
AInputNumber: typeof import('@arco-design/web-vue')['InputNumber']
AInputPassword: typeof import('@arco-design/web-vue')['InputPassword']
AInputSearch: typeof import('@arco-design/web-vue')['InputSearch']
AInputTag: typeof import('@arco-design/web-vue')['InputTag']
ALayout: typeof import('@arco-design/web-vue')['Layout']
ALayoutHeader: typeof import('@arco-design/web-vue')['LayoutHeader']
ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider']
ALink: typeof import('@arco-design/web-vue')['Link']
AMenu: typeof import('@arco-design/web-vue')['Menu']
AMenuItem: typeof import('@arco-design/web-vue')['MenuItem']
AModal: typeof import('@arco-design/web-vue')['Modal']
AOption: typeof import('@arco-design/web-vue')['Option']
AOverflowList: typeof import('@arco-design/web-vue')['OverflowList']
APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
APopover: typeof import('@arco-design/web-vue')['Popover']
AProgress: typeof import('@arco-design/web-vue')['Progress']
ARadio: typeof import('@arco-design/web-vue')['Radio']
ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup']
ARow: typeof import('@arco-design/web-vue')['Row']
AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
ASelect: typeof import('@arco-design/web-vue')['Select']
ASpace: typeof import('@arco-design/web-vue')['Space']
ASpin: typeof import('@arco-design/web-vue')['Spin']
ASplit: typeof import('@arco-design/web-vue')['Split']
ASubMenu: typeof import('@arco-design/web-vue')['SubMenu']
ASwitch: typeof import('@arco-design/web-vue')['Switch']
ATable: typeof import('@arco-design/web-vue')['Table']
ATableColumn: typeof import('@arco-design/web-vue')['TableColumn']
ATabPane: typeof import('@arco-design/web-vue')['TabPane']
ATabs: typeof import('@arco-design/web-vue')['Tabs']
ATag: typeof import('@arco-design/web-vue')['Tag']
ATextarea: typeof import('@arco-design/web-vue')['Textarea']
ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
ATree: typeof import('@arco-design/web-vue')['Tree']
ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect']
AUpload: typeof import('@arco-design/web-vue')['Upload']
GiButton: typeof import('./components/GiButton/GiButton.vue')['default']
GiCellAvatar: typeof import('./components/GiCell/GiCellAvatar.vue')['default']
GiCellGender: typeof import('./components/GiCell/GiCellGender.vue')['default']
GiCellStatus: typeof import('./components/GiCell/GiCellStatus.vue')['default']
GiCellTags: typeof import('./components/GiCell/GiCellTags.vue')['default']
GiDot: typeof import('./components/GiDot/GiDot.tsx')['default']
GiEditTable: typeof import('./components/GiEditTable/GiEditTable.vue')['default']
GiFlex: typeof import('./components/GiFlex/GiFlex.vue')['default']
GiForm: typeof import('./components/GiForm/GiForm.vue')['default']
GiIconBox: typeof import('./components/GiIconBox/GiIconBox.vue')['default']
GiInputSearch: typeof import('./components/GiInputSearch/GiInputSearch.vue')['default']
GiOption: typeof import('./components/GiOption/GiOption.vue')['default']
GiOptionItem: typeof import('./components/GiOption/GiOptionItem.vue')['default']
GiPageLayout: typeof import('./components/GiPageLayout/GiPageLayout.vue')['default']
GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SplitButton: typeof import('./components/GiPageLayout/SplitButton.vue')['default']
}
}
// For TSX support
declare global {
const AAlert: typeof import('@arco-design/web-vue')['Alert']
const AAvatar: typeof import('@arco-design/web-vue')['Avatar']
const ABadge: typeof import('@arco-design/web-vue')['Badge']
const ABreadcrumb: typeof import('@arco-design/web-vue')['Breadcrumb']
const ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem']
const AButton: typeof import('@arco-design/web-vue')['Button']
const ACard: typeof import('@arco-design/web-vue')['Card']
const ACheckbox: typeof import('@arco-design/web-vue')['Checkbox']
const ACheckboxGroup: typeof import('@arco-design/web-vue')['CheckboxGroup']
const ACol: typeof import('@arco-design/web-vue')['Col']
const AComment: typeof import('@arco-design/web-vue')['Comment']
const AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider']
const ADatePicker: typeof import('@arco-design/web-vue')['DatePicker']
const ADescriptions: typeof import('@arco-design/web-vue')['Descriptions']
const ADescriptionsItem: typeof import('@arco-design/web-vue')['DescriptionsItem']
const ADivider: typeof import('@arco-design/web-vue')['Divider']
const ADoption: typeof import('@arco-design/web-vue')['Doption']
const ADrawer: typeof import('@arco-design/web-vue')['Drawer']
const ADropdown: typeof import('@arco-design/web-vue')['Dropdown']
const AEmpty: typeof import('@arco-design/web-vue')['Empty']
const AForm: typeof import('@arco-design/web-vue')['Form']
const AFormItem: typeof import('@arco-design/web-vue')['FormItem']
const AGrid: typeof import('@arco-design/web-vue')['Grid']
const AGridItem: typeof import('@arco-design/web-vue')['GridItem']
const AInput: typeof import('@arco-design/web-vue')['Input']
const AInputGroup: typeof import('@arco-design/web-vue')['InputGroup']
const AInputNumber: typeof import('@arco-design/web-vue')['InputNumber']
const AInputPassword: typeof import('@arco-design/web-vue')['InputPassword']
const AInputSearch: typeof import('@arco-design/web-vue')['InputSearch']
const AInputTag: typeof import('@arco-design/web-vue')['InputTag']
const ALayout: typeof import('@arco-design/web-vue')['Layout']
const ALayoutHeader: typeof import('@arco-design/web-vue')['LayoutHeader']
const ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider']
const ALink: typeof import('@arco-design/web-vue')['Link']
const AMenu: typeof import('@arco-design/web-vue')['Menu']
const AMenuItem: typeof import('@arco-design/web-vue')['MenuItem']
const AModal: typeof import('@arco-design/web-vue')['Modal']
const AOption: typeof import('@arco-design/web-vue')['Option']
const AOverflowList: typeof import('@arco-design/web-vue')['OverflowList']
const APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
const APopover: typeof import('@arco-design/web-vue')['Popover']
const AProgress: typeof import('@arco-design/web-vue')['Progress']
const ARadio: typeof import('@arco-design/web-vue')['Radio']
const ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup']
const ARow: typeof import('@arco-design/web-vue')['Row']
const AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
const ASelect: typeof import('@arco-design/web-vue')['Select']
const ASpace: typeof import('@arco-design/web-vue')['Space']
const ASpin: typeof import('@arco-design/web-vue')['Spin']
const ASplit: typeof import('@arco-design/web-vue')['Split']
const ASubMenu: typeof import('@arco-design/web-vue')['SubMenu']
const ASwitch: typeof import('@arco-design/web-vue')['Switch']
const ATable: typeof import('@arco-design/web-vue')['Table']
const ATableColumn: typeof import('@arco-design/web-vue')['TableColumn']
const ATabPane: typeof import('@arco-design/web-vue')['TabPane']
const ATabs: typeof import('@arco-design/web-vue')['Tabs']
const ATag: typeof import('@arco-design/web-vue')['Tag']
const ATextarea: typeof import('@arco-design/web-vue')['Textarea']
const ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
const ATree: typeof import('@arco-design/web-vue')['Tree']
const ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect']
const AUpload: typeof import('@arco-design/web-vue')['Upload']
const GiButton: typeof import('./components/GiButton/GiButton.vue')['default']
const GiCellAvatar: typeof import('./components/GiCell/GiCellAvatar.vue')['default']
const GiCellGender: typeof import('./components/GiCell/GiCellGender.vue')['default']
const GiCellStatus: typeof import('./components/GiCell/GiCellStatus.vue')['default']
const GiCellTags: typeof import('./components/GiCell/GiCellTags.vue')['default']
const GiDot: typeof import('./components/GiDot/GiDot.tsx')['default']
const GiEditTable: typeof import('./components/GiEditTable/GiEditTable.vue')['default']
const GiFlex: typeof import('./components/GiFlex/GiFlex.vue')['default']
const GiForm: typeof import('./components/GiForm/GiForm.vue')['default']
const GiIconBox: typeof import('./components/GiIconBox/GiIconBox.vue')['default']
const GiInputSearch: typeof import('./components/GiInputSearch/GiInputSearch.vue')['default']
const GiOption: typeof import('./components/GiOption/GiOption.vue')['default']
const GiOptionItem: typeof import('./components/GiOption/GiOptionItem.vue')['default']
const GiPageLayout: typeof import('./components/GiPageLayout/GiPageLayout.vue')['default']
const GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
const GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
const GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
const GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const SplitButton: typeof import('./components/GiPageLayout/SplitButton.vue')['default']
}
@@ -1,86 +0,0 @@
<template>
<a-button v-bind="buttonProps" @click="emit('click')">
<template v-if="buttonConfig?.icon" #icon>
<component :is="buttonConfig.icon" />
</template>
<template #default>
<slot>{{ buttonConfig?.text }}</slot>
</template>
</a-button>
</template>
<script setup lang="ts">
import type { ButtonProps as AButtonProps } from '@arco-design/web-vue'
import type { Component } from 'vue'
import type { ButtonProps as Props } from './type'
import { IconDelete, IconDownload, IconEdit, IconImport, IconPlus, IconSearch, IconUpload } from '@arco-design/web-vue/es/icon'
import { computed } from 'vue'
defineOptions({
name: 'GiButton'
})
const props = withDefaults(defineProps<Props>(), {
type: ''
})
const emit = defineEmits<{
(e: 'click'): void
}>()
/** 按钮配置类型 */
interface ButtonConfig {
icon?: Component
text: string
status?: AButtonProps['status']
type?: AButtonProps['type']
}
/** 按钮类型映射表 */
const BUTTON_MAP: Record<string, ButtonConfig> = {
add: { icon: IconPlus, text: '新增' },
edit: { icon: IconEdit, text: '编辑' },
delete: { icon: IconDelete, text: '删除', status: 'danger' },
search: { icon: IconSearch, text: '搜索' },
reset: { icon: undefined, text: '重置', type: 'secondary' },
upload: { icon: IconUpload, text: '上传', type: 'secondary' },
import: { icon: IconImport, text: '导入', type: 'secondary' },
export: { icon: IconDownload, text: '导出', type: 'secondary' }
}
/** Arco 原生按钮类型 */
const ARCO_BUTTON_TYPES: AButtonProps['type'][] = ['primary', 'secondary', 'outline', 'dashed', 'text']
/** 当前按钮配置 */
const buttonConfig = computed<ButtonConfig | undefined>(() => {
if (!props.type) {
return undefined
}
return BUTTON_MAP[props.type]
})
/** 按钮属性计算 */
const buttonProps = computed(() => {
// 判断是否为 Arco 原生类型
const isArcoType = ARCO_BUTTON_TYPES.includes(props.type as AButtonProps['type'])
// 确定按钮类型
let buttonType: AButtonProps['type'] = 'primary'
if (isArcoType) {
buttonType = props.type as AButtonProps['type']
} else if (buttonConfig.value?.type) {
buttonType = buttonConfig.value.type
}
// 确定按钮状态
const buttonStatus: AButtonProps['status'] = buttonConfig.value?.status || 'normal'
return {
...props,
type: buttonType,
status: buttonStatus
}
})
</script>
<style lang='scss' scoped></style>
@@ -1,5 +0,0 @@
import Button from './GiButton.vue'
export type ButtonInstance = InstanceType<typeof Button>
export * from './type'
export { Button as GiButton }
@@ -1,5 +0,0 @@
import type { ButtonProps as ArcoButtonProps } from '@arco-design/web-vue'
export interface ButtonProps extends Omit<ArcoButtonProps, 'type'> {
type?: 'add' | 'edit' | 'delete' | 'search' | 'reset' | 'upload' | 'import' | 'export' | ''
}
@@ -1,31 +0,0 @@
<template>
<a-space fill>
<a-avatar :size="24" shape="circle">
<img :src="props.avatar" />
</a-avatar>
<a-link v-if="props.isLink" @click="emit('click')">{{ props.name }}</a-link>
<span v-else>{{ props.name }}</span>
</a-space>
</template>
<script lang="ts" setup>
defineOptions({ name: 'GiCellAvatar' })
const props = withDefaults(defineProps<Props>(), {
avatar: '',
name: '',
isLink: false // 是否可以点击
})
const emit = defineEmits<{
(e: 'click'): void
}>()
interface Props {
avatar?: string
name?: string
isLink?: boolean
}
</script>
<style lang="scss" scoped></style>
@@ -1,28 +0,0 @@
<template>
<a-tag v-if="props.gender === '1'" color="arcoblue" class="g-round">
<template #icon><icon-man /></template>
<template #default>男</template>
</a-tag>
<a-tag v-if="props.gender === '2'" color="purple" class="g-round">
<template #icon><icon-woman /></template>
<template #default>女</template>
</a-tag>
<a-tag v-if="props.gender === '3'" color="gray" class="g-round">
<template #icon><icon-lock /></template>
<template #default>保密</template>
</a-tag>
</template>
<script lang="ts" setup>
defineOptions({ name: 'GiCellGender' })
const props = withDefaults(defineProps<Props>(), {
gender: '1'
})
interface Props {
gender?: Gender
}
</script>
<style lang="scss" scoped></style>
@@ -1,24 +0,0 @@
<template>
<a-tag v-if="props.status === '1'" color="arcoblue">
<template #icon><icon-check-circle-fill /></template>
<template #default>正常</template>
</a-tag>
<a-tag v-if="props.status === '0'" color="orangered">
<template #icon><icon-minus-circle-fill /></template>
<template #default>禁用</template>
</a-tag>
</template>
<script lang="ts" setup>
defineOptions({ name: 'GiCellStatus' })
const props = withDefaults(defineProps<Props>(), {
status: '1'
})
interface Props {
status?: Status
}
</script>
<style lang="scss" scoped></style>
@@ -1,33 +0,0 @@
<template>
<a-overflow-list v-if="data.length">
<a-tag v-for="(item, index) in data" :key="index">
{{ item }}
</a-tag>
<template #overflow="{ number }">
<a-popover :content-style="{ maxWidth: '300px', padding: '8px 12px' }">
<a-tag color="arcoblue">+{{ number }}</a-tag>
<template #content>
<a-space wrap>
<a-tag v-for="tag in data.filter((i, n) => n >= data.length - number)" :key="tag">
{{ tag }}
</a-tag>
</a-space>
</template>
</a-popover>
</template>
</a-overflow-list>
</template>
<script lang="ts" setup>
defineOptions({ name: 'GiCellTags' })
withDefaults(defineProps<Props>(), {
data: () => []
})
interface Props {
data?: string[]
}
</script>
<style lang="scss" scoped></style>
@@ -1,6 +0,0 @@
import GiCellAvatar from './GiCellAvatar.vue'
import GiCellGender from './GiCellGender.vue'
import GiCellStatus from './GiCellStatus.vue'
import GiCellTags from './GiCellTags.vue'
export { GiCellAvatar, GiCellGender, GiCellStatus, GiCellTags }
@@ -1,33 +0,0 @@
/**
* @file GiDot 组件 - 用于显示带有可选动画效果的状态点
*/
import type { PropType } from 'vue'
import type { DotType, DotProps as Props } from './type'
import { defineComponent } from 'vue'
import './dot.scss'
export default defineComponent({
name: 'GiDot',
props: {
animation: {
type: Boolean as PropType<boolean>,
default: true,
required: false
},
type: {
type: String as PropType<DotType>,
default: 'primary',
required: false,
validator: (value: string): boolean => {
return ['primary', 'success', 'warning', 'danger', 'info'].includes(value)
}
}
},
setup(props: Props) {
return () => (
<span
class={['gi-dot', { 'gi-dot-processing': props.animation }, `gi-dot-${props.type}`]}
/>
)
}
})
-58
View File
@@ -1,58 +0,0 @@
.gi-dot {
display: inline-block;
width: 8px;
height: 8px;
vertical-align: middle;
background: #000;
border-radius: 50%;
}
$types: primary, success, warning, danger, info;
@each $i in $types {
.gi-dot-#{$i}
{
background-color: rgb(var(--#{$i}-6));
}
@if ($i ==info) {
.gi-dot-#{$i}
{
background-color: rgb(var(--gray-6));
}
}
}
.gi-dot-processing {
position: relative;
}
.gi-dot-processing::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
background: inherit;
border-radius: 50%;
animation: gi-dot-animated 1.2s ease-in-out infinite;
}
@keyframes gi-dot-animated {
0% {
opacity: 1;
transform: scale(0.5);
}
30% {
opacity: 0.7;
}
100% {
opacity: 0;
transform: scale(2.5);
}
}
@@ -1,5 +0,0 @@
import Dot from './GiDot.tsx'
export type * from './type'
export type DotInstance = InstanceType<typeof Dot>
export { Dot as GiDot }
-10
View File
@@ -1,10 +0,0 @@
/** 点的类型 */
export type DotType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
/** 组件属性定义 */
export interface DotProps {
/** 点的类型 */
type?: DotType
/** 是否启用动画效果 */
animation?: boolean
}
@@ -1,176 +0,0 @@
<template>
<div class="gi-edit-table">
<a-form ref="formRef" scroll-to-first-error :model="form">
<a-table :data="form.tableData" :bordered="{ cell: true }" :pagination="false" v-bind="attrs">
<template #columns>
<a-table-column v-for="col in props.columns" :key="col.dataIndex" :data-index="col.dataIndex"
:header-cell-class="headerCellClass(col)" v-bind="col.columnProps" :title="col.title">
<template #title>
<component :is="col?.columnProps?.title" v-if="typeof col?.columnProps?.title === 'function'"></component>
<template v-else>{{ col?.columnProps?.title || col.title }}</template>
</template>
<template #cell="{ record, rowIndex, column }">
<template v-if="!col.type && !col.slotName">{{ record[col.dataIndex] }}</template>
<a-form-item v-else :field="`tableData[${rowIndex}].${col.dataIndex}`"
:label-col-style="{ display: 'none' }" :wrapper-col-props="{ span: 24 }" v-bind="col.formItemProps"
:rules="[{ required: col.required || false, message: getRuleMessage(col) }, ...(col.rules || [])]">
<template v-if="col.slotName">
<slot :name="col.dataIndex" v-bind="{ record, rowIndex, column }"></slot>
</template>
<component :is="COMP_MAP[col.type!]" v-else-if="col.type" v-bind="getComponentBindProps(col)"
v-model="record[col.dataIndex]" :disabled="isDisabled({ row: record, rowIndex, col })">
</component>
</a-form-item>
</template>
</a-table-column>
</template>
<template #tr="{ record, rowIndex }">
<tr class="gi-edit-table-tr" @dblclick="emit('tr-dblclick', { record, rowIndex })"></tr>
</template>
<template #td="{ record, column, rowIndex }">
<td class="gi-edit-table-td" @dblclick="emit('td-dblclick', { record, column, rowIndex })"></td>
</template>
</a-table>
</a-form>
</div>
</template>
<script lang='ts' setup generic="T extends TableData">
import type { TableColumnData, TableData } from '@arco-design/web-vue'
import type { Component } from 'vue'
import type { EditTableColumnItem, EditTableProps as Props } from './type'
import * as A from '@arco-design/web-vue'
defineOptions({ name: 'GiEditTable', inheritAttrs: false })
const props = withDefaults(defineProps<Props<T>>(), {
cellDisabled: false
})
/** 事件定义 */
const emit = defineEmits<{
(e: 'tr-dblclick', value: { record: any, rowIndex: number }): void
(e: 'td-dblclick', value: { record: any, rowIndex: number, column: TableColumnData }): void
}>()
/** 插槽定义 */
defineSlots<{
[propsName: string]: (props: { record: T, rowIndex: number, column: EditTableColumnItem }) => void
}>()
const attrs = useAttrs()
const COMP_MAP: Record<string, Component> = {
'input': A.Input,
'input-number': A.InputNumber,
'input-tag': A.InputTag,
'input-search': A.InputSearch,
'textarea': A.Textarea,
'select': A.Select,
'tree-select': A.TreeSelect,
'radio': A.Radio,
'radio-group': A.RadioGroup,
'checkbox': A.Checkbox,
'checkbox-group': A.CheckboxGroup,
'date-picker': A.DatePicker,
'year-picker': A.YearPicker,
'quarter-picker': A.QuarterPicker,
'month-picker': A.MonthPicker,
'week-picker': A.WeekPicker,
'time-picker': A.TimePicker,
'range-picker': A.RangePicker,
'color-picker': A.ColorPicker,
'rate': A.Rate,
'switch': A.Switch,
'slider': A.Slider,
'cascader': A.Cascader,
'upload': A.Upload,
'auto-complete': A.AutoComplete,
'mention': A.Mention
}
/** 表单数据 */
const form = computed(() => ({ tableData: props.data }))
/** 表单实例 */
const formRef = useTemplateRef('formRef')
/** 获取表头必填星号*样式 */
const headerCellClass = (col: EditTableColumnItem) => {
return col.required ? 'g-column-require' : ''
}
/** 静态配置 */
const STATIC_PROPS = new Map<EditTableColumnItem['type'], Partial<EditTableColumnItem['props']>>([
['input', { allowClear: true, maxLength: 20 }],
['textarea', { allowClear: false, maxLength: 200 }],
['input-tag', { allowClear: true }],
['mention', { allowClear: true }],
['select', { allowClear: true }],
['tree-select', { allowClear: true }],
['cascader', { allowClear: true }],
['date-picker', { allowClear: true }],
['time-picker', { allowClear: true }]
])
/** 获取组件默认占位 */
const getPlaceholder = (item: EditTableColumnItem) => {
if (!item.type) return undefined
if (['input', 'input-tag', 'mention'].includes(item.type)) {
return `请输入${item.title}`
}
if (['textarea'].includes(item.type)) {
return `请填写${item.title}`
}
if (['select', 'tree-select', 'cascader'].includes(item.type)) {
return `请选择${item.title}`
}
if (['date-picker'].includes(item.type)) {
return '请选择日期'
}
if (['time-picker'].includes(item.type)) {
return '请选择时间'
}
return undefined
}
/** 获取组件默认配置 */
const getComponentBindProps = (col: EditTableColumnItem) => {
return {
...STATIC_PROPS.get(col.type) || {},
placeholder: getPlaceholder(col),
...col.props
}
}
/** 获取组件默认规则 */
const getRuleMessage = (col: EditTableColumnItem) => {
if (['input', 'input-number', 'input-tag', 'mention'].includes(col.type ?? '')) {
return `请输入${col.title}`
}
if (['textarea'].includes(col.type ?? '')) {
return `请填写${col.title}`
}
return `请选择${col.title}`
}
/** 判断是否禁用 */
const isDisabled: Props<T>['cellDisabled'] = (p) => {
if (typeof props?.cellDisabled === 'boolean') return props.cellDisabled
if (typeof props?.cellDisabled === 'function') return props.cellDisabled(p)
return false
}
/** 暴露表单实例 */
defineExpose({ formRef })
</script>
<style lang='scss' scoped>
:deep(.arco-form-item) {
margin-bottom: 0;
.arco-form-item-message {
display: none;
}
}
</style>
@@ -1,18 +0,0 @@
import type { FormInstance } from '@arco-design/web-vue'
import EditTable from './GiEditTable.vue'
export type * from './type'
/**
* 组件实例类型。
*
* 与 `GiTable/index.ts` 同一个原因:泛型 SFC 不能用 `InstanceType<>` 推导
* (`<script setup generic="T extends TableData">` 让组件类型成为泛型函数签名),
* 所以按 `defineExpose({ formRef })` 的真实形状手工声明。
*/
export interface EditTableInstance {
/** 内层 a-form 的实例(校验用) */
formRef: FormInstance | null
}
export { EditTable as GiEditTable }
@@ -1,81 +0,0 @@
import type * as A from '@arco-design/web-vue'
import type { VNode } from 'vue'
export type EditTableColumnItemType
= | 'input'
| 'input-number'
| 'input-tag'
| 'textarea'
| 'select'
| 'tree-select'
| 'radio'
| 'radio-group'
| 'checkbox'
| 'checkbox-group'
| 'date-picker'
| 'year-picker'
| 'quarter-picker'
| 'month-picker'
| 'week-picker'
| 'time-picker'
| 'range-picker'
| 'color-picker'
| 'rate'
| 'switch'
| 'slider'
| 'cascader'
| 'upload'
| 'auto-complete'
| 'mention'
| ''
export type EditTableComponentProps
= & A.InputInstance['$props']
& A.InputNumberInstance['$props']
& A.InputTagInstance['$props']
& A.TextareaInstance['$props']
& A.SelectInstance['$props']
& A.TreeSelectInstance['$props']
& A.RadioInstance['$props']
& A.RadioGroupInstance['$props']
& A.CheckboxInstance['$props']
& A.CheckboxGroupInstance['$props']
& A.DatePickerInstance['$props']
& A.YearPickerInstance['$props']
& A.QuarterPickerInstance['$props']
& A.MonthPickerInstance['$props']
& A.WeekPickerInstance['$props']
& A.TimePickerInstance['$props']
& A.RangePickerInstance['$props']
& A.ColorPickerInstance['$props']
& A.RateInstance['$props']
& A.SwitchInstance['$props']
& A.SliderInstance['$props']
& A.CascaderInstance['$props']
& A.UploadInstance['$props']
& A.AutoCompleteInstance['$props']
& A.MentionInstance['$props']
interface EditTableColumnItemProps extends Partial<Omit<EditTableComponentProps, 'placeholder'>> {
placeholder?: string | string[]
}
export interface EditTableColumnItem {
type?: EditTableColumnItemType
title: string
dataIndex: string
required?: boolean
rules?: A.FormItemInstance['$props']['rules'] // 表单校验规则
props?: EditTableColumnItemProps
columnProps?: Partial<Omit<A.TableColumnInstance['$props'], 'title'>> & { title?: string | (() => VNode) }
formItemProps?: A.FormItemInstance['$props']
slotName?: string
}
export type EditTableCellDisabled<T> = boolean | ((e: { row: T, rowIndex: number, col: EditTableColumnItem }) => boolean)
export interface EditTableProps<T> {
columns: EditTableColumnItem[]
data: T[]
cellDisabled?: EditTableCellDisabled<T>
}
@@ -1,65 +0,0 @@
<template>
<div class="gi-flex" :class="getClass" :style="getStyle">
<slot />
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import type { FlexProps as Props } from './type'
import { computed } from 'vue'
const props = withDefaults(defineProps<Props>(), {
column: false,
justify: 'start',
align: 'start',
wrap: true,
gap: false
})
/** 计算组件的 class */
const getClass = computed(() => {
const arr: string[] = []
if (props.wrap) {
arr.push('gi-flex__wrap')
}
if (props.column) {
arr.push('gi-flex__column')
}
return arr.join(' ')
})
/** 计算组件的样式 */
const getStyle = computed((): CSSProperties => {
const style: CSSProperties = {
'justify-content': props.justify,
'align-items': props.align
}
// 联合类型中若 boolean 不在最前,裸写 `gap` 会得到 `""`;与「开启主题间距」语义一致
if (props.gap === true || props.gap === '') {
style.gap = 'var(--margin)'
} else if (props.gap === false) {
style.gap = undefined
} else if (typeof props.gap === 'number') {
style.gap = `${props.gap}px`
} else {
style.gap = props.gap
}
style.flex = props.flex
return style
})
</script>
<style lang="scss" scoped>
.gi-flex {
display: flex;
&.gi-flex__wrap {
flex-wrap: wrap;
}
&.gi-flex__column {
flex-direction: column;
}
}
</style>
@@ -1,5 +0,0 @@
import Flex from './GiFlex.vue'
export type FlexInstance = InstanceType<typeof Flex>
export * from './type'
export { Flex as GiFlex }
-29
View File
@@ -1,29 +0,0 @@
/** 组件属性定义 */
export interface FlexProps {
/** 排列方向 */
column?: boolean
/** 水平对齐方式(与 CSS justify-content 一致) */
justify?:
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'space-between'
| 'space-around'
| 'space-evenly'
/** 垂直对齐方式(与 CSS align-items 一致) */
align?:
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'baseline'
| 'stretch'
/** 是否自动换行 */
wrap?: boolean
/** `boolean` 须写在联合类型最前,否则模板里单独写 `gap` 会被解析成 `""` 而不是 `true` */
gap?: boolean | number | string
flex?: number | string
}
@@ -1,273 +0,0 @@
<!--
@file GiForm 组件
@description 动态表单组件,支持多种表单项类型、栅格布局、搜索模式等功能
-->
<template>
<a-form ref="formRef" v-bind="formProps" :model="modelValue">
<a-grid class="w-full" :col-gap="8" v-bind="props.gridProps" :collapsed="collapsed && search">
<!-- 表单项列表 -->
<template v-for="item in columns" :key="item.field">
<a-grid-item v-if="!isHide(item)" v-bind="item.gridItemProps || props.gridItemProps"
:span="item.span || item.gridItemProps?.span || props.gridItemProps?.span">
<a-form-item v-bind="item.formItemProps" :field="item.field" :rules="getFormItemRules(item)"
:disabled="isDisabled(item)">
<!-- 表单项标签 -->
<template #label>
<component :is="item.labelRender" v-if="item.labelRender"></component>
<template v-else>{{ item.label }}</template>
</template>
<!-- 表单项内容 -->
<slot v-if="getStringType(item.type) === 'slot'" :name="item.field"></slot>
<slot v-else-if="getStringType(item.type) !== 'group-title'" :name="item.field"
v-bind="{ disabled: isDisabled(item) }">
<component :is="(COMP_MAP[getStringType(item.type)] || item.type)" v-bind="getComponentBindProps(item)"
:model-value="modelValue[getModelField(item)]"
@update:model-value="updateValue($event, getModelField(item))">
<!-- 组件插槽 -->
<template v-for="(slotValue, slotKey) in item?.slots" :key="slotKey" #[slotKey]="scope">
<template v-if="typeof slotValue === 'string'">{{ slotValue }}</template>
<template v-else-if="slotValue">
<component :is="slotValue(scope)"></component>
</template>
</template>
</component>
</slot>
<!-- 分组标题 -->
<slot v-else :name="item.field">
<a-alert>{{ item.label }}</a-alert>
</slot>
<!-- 表单项插槽 -->
<template v-for="(slotValue, slotKey) in item?.formItemSlots" :key="slotKey" #[slotKey]>
<template v-if="typeof slotValue === 'string'">{{ slotValue }}</template>
<component :is="slotValue" v-else></component>
</template>
</a-form-item>
</a-grid-item>
</template>
<!-- 搜索按钮组 -->
<a-grid-item v-if="props.search" :key="!props.suffix ? String(collapsed) : undefined" v-bind="props.gridItemProps"
:span="props.gridItemProps?.span" :suffix="props.search && (props.suffix || (!props.suffix && collapsed))">
<a-space wrap style="margin-bottom: 8px">
<slot name="suffix">
<a-button type="primary" @click="emit('search')">
<template #icon><icon-search /></template>
<template #default>{{ props.searchBtnText }}</template>
</a-button>
<a-button @click="emit('reset')">重置</a-button>
<a-button v-if="!props.hideFoldBtn" class="gi-form__fold-btn" type="text" size="mini"
@click="collapsed = !collapsed">
<template #icon>
<icon-up v-if="!collapsed" />
<icon-down v-else />
</template>
<template #default>{{ collapsed ? '展开' : '收起' }}</template>
</a-button>
</slot>
</a-space>
</a-grid-item>
</a-grid>
</a-form>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import type { Component } from 'vue'
import type { FormColumnItem, FormProps as Props } from './type'
import * as A from '@arco-design/web-vue'
import { omit } from 'lodash-es'
// FormProps 默认值
const props = withDefaults(defineProps<Props>(), {
autoLabelWidth: true,
scrollToFirstError: true,
defaultCollapsed: true,
search: false,
gridItemProps: () => ({ span: { xs: 24, sm: 12 } }),
searchBtnText: '搜索',
hideFoldBtn: false,
suffix: true,
fc: () => ({})
})
/** Emits 类型定义 */
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search'): void
(e: 'reset'): void
}>()
const COMP_MAP: Record<string, Component> = {
'input': A.Input,
'input-number': A.InputNumber,
'input-tag': A.InputTag,
'input-search': A.InputSearch,
'textarea': A.Textarea,
'select': A.Select,
'tree-select': A.TreeSelect,
'radio-group': A.RadioGroup,
'checkbox-group': A.CheckboxGroup,
'date-picker': A.DatePicker,
'year-picker': A.YearPicker,
'quarter-picker': A.QuarterPicker,
'month-picker': A.MonthPicker,
'week-picker': A.WeekPicker,
'time-picker': A.TimePicker,
'range-picker': A.RangePicker,
'color-picker': A.ColorPicker,
'rate': A.Rate,
'switch': A.Switch,
'slider': A.Slider,
'cascader': A.Cascader,
'upload': A.Upload,
'auto-complete': A.AutoComplete,
'mention': A.Mention
}
/** 表单属性计算 */
const formProps = computed(() => {
const baseProps = omit(props, [
'columns',
'gridProps',
'gridItemProps',
'search',
'defaultCollapsed',
'searchBtnText',
'hideFoldBtn',
'suffix',
'layout',
'fc'
])
return {
layout: ((props.search ? 'inline' : 'horizontal') as FormInstance['layout']),
...baseProps
}
})
/** 组件状态 */
const formRef = useTemplateRef('formRef')
/** 是否折叠 */
const collapsed = ref(props.defaultCollapsed)
/** 数据字典(预留功能:用于动态加载选项数据,可通过 request 和 format 配置项加载) */
const dicData: Record<string, unknown> = reactive({})
/** 静态配置 */
const STATIC_PROPS = new Map<FormColumnItem['type'], Partial<FormColumnItem['props']>>([
['input', { allowClear: true, maxLength: 20 }],
['textarea', { allowClear: false, maxLength: 200 }],
['input-tag', { allowClear: true }],
['mention', { allowClear: true }],
['select', { allowClear: true }],
['tree-select', { allowClear: true }],
['cascader', { allowClear: true }],
['date-picker', { allowClear: true }],
['time-picker', { allowClear: true }]
])
/** 占位符映射表 */
const PLACEHOLDER_MAP: Record<string, (label?: string) => string | undefined> = {
'input': (label) => `请输入${label}`,
'input-tag': (label) => `请输入${label}`,
'mention': (label) => `请输入${label}`,
'textarea': (label) => `请填写${label}`,
'select': (label) => `请选择${label}`,
'input-search': (label) => `请选择${label}`,
'tree-select': (label) => `请选择${label}`,
'cascader': (label) => `请选择${label}`,
'date-picker': () => '请选择日期',
'time-picker': () => '请选择时间'
}
/** 获取字符串类型的表单项类型 */
const getStringType = (type: FormColumnItem['type']): string => {
return typeof type === 'string' ? type : ''
}
/** 获取模型字段名 */
const getModelField = (item: FormColumnItem) => item?.fieldName || item.field
/** 获取组件默认占位 */
const getPlaceholder = (item: FormColumnItem) => {
if (!item.type || typeof item.type !== 'string') return undefined
const placeholderFn = PLACEHOLDER_MAP[item.type]
return placeholderFn ? placeholderFn(item.label) : undefined
}
/** 获取选项数据 */
const getOptions = (item: FormColumnItem) => {
if (!item.type) return undefined
/** 需要选项数据的组件类型 */
const arr = ['select', 'tree-select', 'cascader', 'radio-group', 'checkbox-group']
if (typeof item.type === 'string' && arr.includes(item.type)) {
return dicData[item.field] || []
}
return undefined
}
/** 获取组件绑定属性 */
const getComponentBindProps = (item: FormColumnItem) => {
return {
...STATIC_PROPS.get(item.type) || {},
placeholder: getPlaceholder(item),
options: getOptions(item),
...item.props
}
}
/** 表单数据更新处理 */
const updateValue = (value: unknown, field: string) => {
const newValue = { ...props.modelValue, [field]: value }
emit('update:modelValue', newValue)
}
/** 获取表单项校验规则 */
const getFormItemRules = (item: FormColumnItem) => {
if (item.required) {
return [
{ required: true, message: `${item.label}为必填项` },
...(Array.isArray(item.rules) ? item.rules : [])
]
}
if (props.fc?.[item.field]?.required) {
return [
{
required: props.fc?.[item.field]?.required,
message: `${item.label}为必填项`
},
...(Array.isArray(item.rules) ? item.rules : [])
]
}
return item.rules
}
/** 判断表单项是否隐藏 */
const isHide = (item: FormColumnItem) => {
if (typeof item.hide === 'boolean') return item.hide
if (typeof item.hide === 'function') {
return item.hide(props.modelValue)
}
if (props.fc?.[item.field]?.hidden) return true
return false
}
/** 判断表单项是否禁用 */
const isDisabled = (item: FormColumnItem) => {
if (typeof item.disabled === 'boolean') return item.disabled
if (typeof item.disabled === 'function') {
return item.disabled(props.modelValue)
}
if (props.fc?.[item.field]?.disabled === true) return true
if (item.disabled === undefined) return (item?.props?.disabled as boolean) ?? false
}
defineExpose({ formRef })
</script>
<style lang="scss" scoped>
.gi-form__fold-btn {
padding: 0 5px;
}
</style>
@@ -1,5 +0,0 @@
import Form from './GiForm.vue'
export type * from './type'
export type FormInstance = InstanceType<typeof Form>
export { Form as GiForm }
-299
View File
@@ -1,299 +0,0 @@
/**
* @file GiForm 组件类型定义
* @description 包含表单组件所需的所有类型定义,用于构建动态表单
*/
import type * as A from '@arco-design/web-vue'
import type { GridItemProps } from '@arco-design/web-vue'
import type { Component, VNode } from 'vue'
import type { MergeMultiple } from '@/types/tool'
type FormItemProps = A.FormItemInstance['$props']
/** 支持的表单项类型 */
export type FormColumnItemType
= | 'input' // 输入框
| 'input-number' // 数字输入框
| 'input-tag' // 标签输入框
| 'input-search' // 搜索框
| 'textarea' // 文本域
| 'select' // 选择器
| 'tree-select' // 树选择器
| 'radio' // 单选框
| 'radio-group' // 单选框组
| 'checkbox' // 复选框
| 'checkbox-group' // 复选框组
| 'date-picker' // 日期选择器
| 'year-picker' // 年份选择器
| 'quarter-picker' // 季度选择器
| 'month-picker' // 月份选择器
| 'week-picker' // 周选择器
| 'time-picker' // 时间选择器
| 'range-picker' // 范围选择器
| 'color-picker' // 颜色选择器
| 'rate' // 评分
| 'switch' // 开关
| 'slider' // 滑块
| 'cascader' // 级联选择器
| 'upload' // 上传
| 'auto-complete' // 自动完成
| 'mention' // 提及
| 'group-title' // 分组标题
| 'slot' // 插槽,由父组件通过具名插槽渲染内容
| Component
/** 组件属性合并类型 */
export type FormComponentProps = MergeMultiple<[
Partial<A.InputInstance['$props']>,
Partial<A.InputNumberInstance['$props']>,
Partial<A.InputTagInstance['$props']>,
Partial<A.InputSearchInstance['$props']>,
Partial<A.TextareaInstance['$props']>,
Partial<A.SelectInstance['$props']>,
Partial<A.TreeSelectInstance['$props']>,
Partial<A.RadioInstance['$props']>,
Partial<A.RadioGroupInstance['$props']>,
Partial<A.CheckboxInstance['$props']>,
Partial<A.CheckboxGroupInstance['$props']>,
Partial<A.DatePickerInstance['$props']>,
Partial<A.YearPickerInstance['$props']>,
Partial<A.QuarterPickerInstance['$props']>,
Partial<A.MonthPickerInstance['$props']>,
Partial<A.WeekPickerInstance['$props']>,
Partial<A.TimePickerInstance['$props']>,
Partial<A.RangePickerInstance['$props']>,
Partial<A.ColorPickerInstance['$props']>,
Partial<A.RateInstance['$props']>,
Partial<A.SwitchInstance['$props']>,
Partial<A.SliderInstance['$props']>,
Partial<A.CascaderInstance['$props']>,
Partial<A.UploadInstance['$props']>,
Partial<A.AutoCompleteInstance['$props']>,
Partial<A.MentionInstance['$props']>,
Partial<A.AlertInstance['$props']>
]>
/** 选项类型定义 */
export type FormColumnItemOptions
= MergeMultiple<[A.SelectInstance['$props']['options'], A.RadioGroupInstance['$props']['options'], A.CheckboxGroupInstance['$props']['options'], A.CascaderInstance['$props']['options']]>
/** 数据源类型定义 */
export type FormColumnItemData
= MergeMultiple<[A.TreeSelectInstance['$props']['data'], A.AutoCompleteInstance['$props']['data'], A.MentionInstance['$props']['data']]>
/** 自动完成组件插槽 */
type AutoCompleteSlots = {
option: (e: { data: (string | number | A.SelectOptionData | A.SelectOptionGroup)[] }) => VNode
footer: () => VNode
}
/** 级联选择器插槽 */
type CascaderSlots = {
'label': (e: { data: A.CascaderOption }) => VNode
'prefix': () => VNode
'arrow-icon': () => VNode
'loading-icon': () => VNode
'search-icon': () => VNode
'empty': () => VNode
'option': (e: { data: A.CascaderOption }) => VNode
}
/** 复选框组插槽 */
type CheckboxGroupSlots = {
checkbox: (e: { checked: boolean, disabled: string }) => VNode
label: (e: { data: A.CheckboxOption }) => VNode
}
/** 单选框组插槽 */
type RadioGroupSlots = {
radio: (e: { checked: boolean, disabled: string }) => VNode
label: (e: { data: any }) => VNode
}
/** 日期选择器插槽 */
type DatePickerSlots = {
'prefix': () => VNode
'suffix-icon': () => VNode
'icon-next-double': () => VNode
'icon-prev-double': () => VNode
'icon-next': () => VNode
'icon-prev': () => VNode
'cell': (e: { data: Date }) => VNode
'extra': () => VNode
}
/** 输入框插槽 */
type InputSlots = {
append: (() => VNode) | string
prepend: (() => VNode) | string
suffix: (() => VNode) | string
prefix: (() => VNode) | string
}
/** 数字输入框插槽 */
type InputNumberSlots = {
minus: (() => VNode) | string
plus: (() => VNode) | string
append: (() => VNode) | string
prepend: (() => VNode) | string
suffix: (() => VNode) | string
}
/** 标签输入框插槽 */
type InputTagSlots = {
tag: (e: { data: A.TagData }) => VNode
prefix: (() => VNode) | string
suffix: (() => VNode) | string
}
/** 评分插槽 */
type RateSlots = {
character: (e: { index: number }) => VNode
}
/** 选择器插槽 */
type SelectSlots = {
'trigger': () => VNode
'prefix': () => VNode
'search-icon': () => VNode
'loading-icon': () => VNode
'arrow-icon': () => VNode
'footer': () => VNode
'header': () => VNode
'label': (e: { data: A.SelectOptionData }) => VNode
'option': (e: { data: A.SelectOptionData }) => VNode
'empty': () => VNode
}
/** 开关插槽 */
type SwitchSlots = {
'checked-icon': () => VNode
'unchecked-icon': () => VNode
'checked': () => VNode
'unchecked': () => VNode
}
/** 树选择器插槽 */
type TreeSelectSlots = {
'trigger': () => VNode
'prefix': () => VNode
'label': (e: { data: any }) => VNode
'header': () => VNode
'loader': () => VNode
'empty': () => VNode
'footer': () => VNode
'tree-slot-extra': () => VNode
'tree-slot-title': (e: { title: string }) => VNode
'tree-slot-icon': (e: { node: A.TreeNodeData }) => VNode
'tree-slot-switcher-icon': () => VNode
}
/** 提及插槽 */
type MentionSlots = {
option: (e: { data: any }) => VNode
}
/** 组件插槽合并类型 */
export type FormComponentSlots = MergeMultiple<[
AutoCompleteSlots,
CascaderSlots,
CheckboxGroupSlots,
RadioGroupSlots,
DatePickerSlots,
InputSlots,
InputNumberSlots,
InputTagSlots,
RateSlots,
SelectSlots,
SwitchSlots,
TreeSelectSlots,
MentionSlots
]>
/** 表单项插槽接口 */
export type FormColumnItemSlots = FormComponentSlots
/** 表单项显隐控制类型 */
export type FormColumnItemHide<F> = boolean | ((form: F) => boolean)
export type FormColumnItemDisabled<F> = boolean | ((form: F) => boolean)
/** 表单项数据请求相关类型 */
export type FormColumnItemRequest<F = any> = (form: F) => Promise<any>
export type FormColumnItemFormat<T = any> = (res: T) => FormColumnItemOptions | FormColumnItemData
/** 表单列配置项接口 */
export interface FormColumnItem<F = any> {
/** 表单项类型 */
type?: FormColumnItemType
/** 标签名 */
label?: string
/** 字段名 */
field: string
fieldName?: string
/** 自定义渲染label */
labelRender?: (() => VNode)
/** 表单项属性 */
props?: Partial<FormComponentProps>
/** 表单项校验规则 */
rules?: FormItemProps['rules']
/** 是否必填 */
required?: boolean
/** 栅格属性 */
gridItemProps?: GridItemProps
/** 表单项属性 */
formItemProps?: FormItemProps
/** 表单项插槽 */
slots?: FormColumnItemSlots
/** 表单项组件插槽 */
formItemSlots?: Record<string, string | VNode | (() => VNode)>
/** 是否隐藏(支持函数) */
hide?: boolean | ((model: F) => boolean)
/** 是否禁用(支持函数) */
disabled?: boolean | ((model: F) => boolean)
/** 栅格跨度 */
span?: number | Record<string, number>
}
/** Props 类型定义 */
export interface FormProps {
/** 表单数据对象 */
modelValue: any
/** 表单布局方式 */
layout?: A.FormInstance['layout']
/** 表单尺寸 */
size?: A.FormInstance['size']
/** 标签布局属性 */
labelColProps?: A.FormInstance['labelColProps']
/** 表单项布局属性 */
wrapperColProps?: A.FormInstance['wrapperColProps']
/** 标签对齐方式 */
labelAlign?: A.FormInstance['labelAlign']
/** 是否禁用表单 */
disabled?: A.FormInstance['disabled']
/** 表单校验规则 */
rules?: A.FormInstance['rules']
/** 自动标签宽度 */
autoLabelWidth?: A.FormInstance['autoLabelWidth']
/** 表单ID */
id?: A.FormInstance['id']
/** 是否滚动到第一个错误项 */
scrollToFirstError?: A.FormInstance['scrollToFirstError']
/** 表单列配置 */
columns: FormColumnItem[]
/** 栅格布局属性 */
gridProps?: A.GridProps
/** 栅格项属性 */
gridItemProps?: GridItemProps
/** 是否为搜索模式 */
search?: boolean
/** 默认是否折叠 */
defaultCollapsed?: boolean
/** 搜索按钮文字 */
searchBtnText?: string
/** 是否隐藏折叠按钮 */
hideFoldBtn?: boolean
/** 是否显示后缀 */
suffix?: boolean
/** 表单控制 */
fc?: Record<string, { disabled?: boolean, hidden?: boolean, required?: boolean }>
}
@@ -1,99 +0,0 @@
<!--
@file GiIconBox 组件 - 图标容器
@description 提供可自定义的图标容器,支持不同颜色、尺寸和形状
-->
<template>
<div class="gi-icon-box" :class="getClass" :style="getStyle">
<slot />
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import type { IconBoxProps as Props } from './type'
import { computed } from 'vue'
const props = withDefaults(defineProps<Props>(), {
color: '#165DFF',
size: 20,
shape: 'square'
})
/** 预设状态颜色 */
const STATUS_COLORS = ['primary', 'success', 'warning', 'danger'] as const
/** 计算容器的 class */
const getClass = computed(() => {
const classes = [
STATUS_COLORS.includes(props.color as typeof STATUS_COLORS[number])
&& `gi-icon-box--${props.color}`,
props.shape && `gi-icon-box__shape--${props.shape}`
].filter(Boolean)
return classes.join(' ')
})
/** 计算容器的样式 */
const getStyle = computed((): CSSProperties => {
const style: CSSProperties = {}
// 处理自定义颜色(纯色或 linear-gradient / radial-gradient)
if (!STATUS_COLORS.includes(props.color as typeof STATUS_COLORS[number])) {
const c = String(props.color)
if (/gradient\(/i.test(c)) {
style.background = c
} else {
style.backgroundColor = c
}
}
// 处理自定义尺寸
if (props.size !== 20) {
style.width = `${props.size}px`
style.height = `${props.size}px`
}
return style
})
</script>
<style lang="scss" scoped>
.gi-icon-box {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: #fff;
transition: all 0.2s ease;
// 状态颜色
&--primary {
background-color: rgb(var(--primary-6));
}
&--success {
background-color: rgb(var(--success-6));
}
&--warning {
background-color: rgb(var(--warning-6));
}
&--danger {
background-color: rgb(var(--danger-6));
}
// 形状样式
&__shape {
&--square {
border-radius: 4px;
}
&--round {
border-radius: 50%;
}
}
}
</style>
@@ -1,5 +0,0 @@
import IconBox from './GiIconBox.vue'
export * from './type'
export type IconBoxInstance = InstanceType<typeof IconBox>
export { IconBox as GiIconBox }
@@ -1,6 +0,0 @@
/** 组件属性定义 */
export interface IconBoxProps {
color?: 'primary' | 'success' | 'warning' | 'danger' | string
size?: number
shape?: 'square' | 'round'
}
@@ -1,50 +0,0 @@
<template>
<a-input-group class="gi-input-search">
<a-input v-model="model" :placeholder="props.placeholder" readonly></a-input>
<template v-if="!props.readonly">
<a-button :disabled="props.disabled" @click="emit('search', model)">
<icon-search />
</a-button>
<a-button :disabled="props.disabled" @click="emit('clear')">
<icon-close />
</a-button>
</template>
</a-input-group>
</template>
<script setup lang="ts">
import type { InputSearchProps as Props } from './type'
const model = defineModel({ type: String, default: '' })
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择',
disabled: false,
readonly: false
})
const emit = defineEmits<{
(e: 'search', value: string): void
(e: 'clear'): void
}>()
</script>
<style lang="scss" scoped>
.gi-input-search {
display: flex;
width: 100%;
> :deep(.arco-btn) {
flex-shrink: 0;
&:hover,
&:active {
z-index: 1;
}
}
> :deep(.arco-btn + .arco-btn) {
margin-left: -1px;
}
}
</style>
@@ -1,5 +0,0 @@
import InputSearch from './GiInputSearch.vue'
export type InputSearchInstance = InstanceType<typeof InputSearch>
export * from './type'
export { InputSearch as GiInputSearch }
@@ -1,5 +0,0 @@
export interface InputSearchProps {
placeholder?: string
disabled?: boolean
readonly?: boolean
}
@@ -1,16 +0,0 @@
<template>
<ul class="gi-option">
<slot></slot>
</ul>
</template>
<script setup lang="ts">
defineOptions({ name: 'GiOption' })
</script>
<style lang="scss" scoped>
.gi-option {
width: 100%;
min-width: 120px;
}
</style>
@@ -1,79 +0,0 @@
<!--
@file GiOptionItem 组件 - 选项列表项
@description 提供统一的选项列表项样式,支持图标和更多操作
-->
<template>
<li class="gi-option-item" :class="{ 'gi-option-item--more': props.more, 'gi-option-item--active': props.active }"
@click="handleClick">
<div class="gi-option-item__wrapper">
<span class="gi-option-item__icon">
<slot name="icon">
<Icon v-if="props.icon" :icon="props.icon" :width="16" :height="16" />
</slot>
</span>
<slot>
<span class="gi-option-item__label">{{ props.label }}</span>
</slot>
</div>
<IconRight v-if="props.more" />
</li>
</template>
<script setup lang="ts">
import type { OptionItemProps as Props } from './type'
import { Icon } from '@iconify/vue'
defineOptions({ name: 'GiOptionItem' })
const props = withDefaults(defineProps<Props>(), {
icon: '',
label: '',
more: false,
active: false
})
const emit = defineEmits<{
(e: 'click'): void
}>()
/** 处理点击事件 */
const handleClick = () => {
emit('click')
}
</script>
<style lang="scss" scoped>
.gi-option-item {
position: relative;
display: flex;
align-items: center;
height: 34px;
padding: 0 5px 0 10px;
font-size: 14px;
line-height: 34px;
color: var(--color-text-2);
cursor: pointer;
user-select: none;
&__wrapper {
display: flex;
align-items: center;
}
&__icon {
display: flex;
align-items: center;
margin-right: 8px;
}
&--active,
&:hover {
color: rgb(var(--primary-6));
background: var(--color-primary-light-1);
}
&--more {
justify-content: space-between;
}
}
</style>
@@ -1,7 +0,0 @@
import Option from './GiOption.vue'
import OptionItem from './GiOptionItem.vue'
export type OptionInstance = InstanceType<typeof Option>
export type OptionItemInstance = InstanceType<typeof OptionItem>
export * from './type'
export { Option as GiOption, OptionItem as GiOptionItem }
@@ -1,11 +0,0 @@
/** 组件属性定义 */
export interface OptionItemProps {
/** 图标名称 */
icon?: string
/** 选项文本 */
label?: string
/** 是否显示更多图标 */
more?: boolean
/** 是否激活状态 */
active?: boolean
}
@@ -1,192 +0,0 @@
<template>
<a-split v-model:size="size" class="gi-page-layout" :class="getClass" min="1px" max="600px" :disabled="!slots.left">
<template #first>
<div v-if="slots.left" class="gi-page-layout__left" :style="props.leftStyle">
<slot name="left"></slot>
</div>
</template>
<template #second>
<div v-if="slots.header" class="gi-page-layout__header" :style="props.headerStyle">
<slot name="header"></slot>
</div>
<div class="gi-page-layout__body" :style="props.bodyStyle">
<slot></slot>
</div>
</template>
<template v-if="props.collapsed || isMobile" #resize-trigger-icon>
<SplitButton :collapsed="parseInt(size) < 20" @click="handleClick"></SplitButton>
</template>
</a-split>
</template>
<script setup lang='ts'>
import type { PageLayoutProps as Props } from './type'
import { browse } from 'xe-utils'
import SplitButton from './SplitButton.vue'
defineOptions({ name: 'GiPageLayout' })
const props = withDefaults(defineProps<Props>(), {
size: '270px',
margin: false,
inner: false,
headerBordered: true,
leftStyle: () => ({}),
headerStyle: () => ({}),
bodyStyle: () => ({}),
collapsed: false,
bgTransparent: false
})
/** 组件插槽定义 */
defineSlots<{
header: () => void
left: () => void
default: () => void
}>()
const slots = useSlots()
const isMobile = browse().isMobile ?? false
const size = ref(isMobile ? '0px' : props.size)
const collapsing = ref(false)
const getClass = computed(() => {
return {
'gi-page-layout--disabled': !slots.left,
'gi-page-layout--margin': props.margin,
'gi-page-layout--inner': props.inner,
'gi-page-layout--has-header': slots.header,
'gi-page-layout--header-bordered': props.headerBordered,
'gi-page-layout--collapsing': collapsing.value,
'gi-page-layout--bg-transparent': props.bgTransparent
}
})
function handleClick() {
collapsing.value = true
setTimeout(() => {
collapsing.value = false
}, 300)
size.value = Number.parseInt(size.value) > 20 ? '0px' : props.size
}
</script>
<style lang='scss' scoped>
:deep(.arco-split-pane) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
:deep(.arco-split-trigger) {
position: relative;
}
:deep(.arco-split-trigger-icon-wrapper) {
position: absolute;
top: 50%;
left: 50%;
z-index: 9;
width: 16px;
height: 100%;
user-select: none;
background-color: transparent;
transform: translate(-50%, -50%);
.arco-split-trigger-icon {
display: none;
}
&:hover,
&:active {
&::before {
width: 2px;
background-color: var(--color-primary-light-2);
}
}
&::before {
position: absolute;
top: 0;
left: 50%;
width: 1px;
height: 100%;
content: '';
background-color: var(--color-border-2);
transform: translateX(-50%);
}
}
.gi-page-layout {
box-sizing: border-box;
display: flex;
flex: 1;
width: auto;
height: 100%;
overflow: hidden;
background-color: var(--color-bg-1);
}
.gi-page-layout--bg-transparent {
background-color: transparent;
}
.gi-page-layout--margin {
margin: var(--margin);
}
.gi-page-layout--inner {
.gi-page-layout__header {
padding: 0;
}
.gi-page-layout__body {
padding-right: 0;
padding-bottom: 0;
padding-left: 0;
}
}
.gi-page-layout--header-bordered {
.gi-page-layout__header {
border-bottom: 1px solid var(--color-border);
}
}
.gi-page-layout--disabled {
:deep(> .arco-split-pane-first) {
display: none;
}
}
.gi-page-layout--collapsing {
:deep(> .arco-split-pane-first) {
transition: flex-basis 0.3s;
}
}
.gi-page-layout__left {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
padding: var(--padding);
overflow: hidden;
}
.gi-page-layout__header {
box-sizing: border-box;
padding: var(--padding);
padding-bottom: 0;
}
.gi-page-layout__body {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
padding: var(--padding);
overflow: hidden;
}
</style>
@@ -1,100 +0,0 @@
<!--
@file GiSplitButton 组件 - 分割面板切换按钮
@description 提供可折叠面板的切换按钮,支持多种样式和交互效果
-->
<template>
<div class="gi-split-button" :class="buttonClass" @click="handleClick">
<icon-right v-if="collapsed" :size="iconSize" />
<icon-left v-else :size="iconSize" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
/** 按钮类型 */
type ButtonType = 'default' | 'circle'
/** 组件属性定义 */
interface Props {
/** 是否折叠状态 */
collapsed?: boolean
/** 按钮类型 */
type?: ButtonType
/** 图标大小 */
iconSize?: number
/** 是否禁用 */
disabled?: boolean
}
/** 组件事件定义 */
interface Emits {
(e: 'click'): void
(e: 'update:collapsed', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
collapsed: false,
type: 'circle',
iconSize: 14,
disabled: false
})
const emit = defineEmits<Emits>()
/** 计算按钮类名 */
const buttonClass = computed(() => [
`gi-split-button--${props.type}`,
{
'is-collapsed': props.collapsed,
'is-disabled': props.disabled
}
])
/** 处理点击事件 */
const handleClick = () => {
if (props.disabled) return
emit('click')
emit('update:collapsed', !props.collapsed)
}
</script>
<style lang="scss" scoped>
.gi-split-button {
position: absolute;
top: 50%;
z-index: 2;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background-color: var(--color-bg-1);
border: 1px solid var(--color-border-2);
transform: translateY(-50%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform, background-color, border-color;
&.is-disabled {
pointer-events: none;
cursor: not-allowed;
opacity: 0.6;
}
&--default {
left: 0;
width: 18px;
height: 40px;
box-shadow: 2px 0 6px rgb(0 0 0 / 10%);
}
&--circle {
left: -4px;
width: 24px;
height: 24px;
overflow: hidden;
border-radius: 50%;
box-shadow: 0 4px 10px rgb(0 0 0 / 10%);
}
}
</style>
@@ -1,5 +0,0 @@
import PageLayout from './GiPageLayout.vue'
export type PageLayoutInstance = InstanceType<typeof PageLayout>
export * from './type'
export { PageLayout as GiPageLayout }
@@ -1,14 +0,0 @@
import type { CSSProperties } from 'vue'
/** 组件属性定义 */
export interface PageLayoutProps {
size?: string
margin?: boolean
inner?: boolean
headerBordered?: boolean
leftStyle?: CSSProperties
headerStyle?: CSSProperties
bodyStyle?: CSSProperties
collapsed?: boolean
bgTransparent?: boolean
}
@@ -1,440 +0,0 @@
<!--
@file GiTable 组件
@description 增强型表格组件,支持全屏、列设置、斑马纹、边框等功能
-->
<template>
<div class="gi-table" :class="{ 'gi-table--fullscreen': isFullscreen }">
<!-- 表格头部区域 -->
<a-row justify="space-between" align="center" class="g-row-tool">
<!-- 左侧标题区域 -->
<a-space wrap>
<slot name="custom-title">
<div class="gi-table__title">{{ props.title }}</div>
</slot>
</a-space>
<!-- 表格操作按钮组 -->
<a-space wrap>
<slot name="custom-extra"></slot>
<!-- 斑马纹开关 -->
<a-tooltip content="斑马纹">
<a-switch v-model="stripe" size="small" type="round" />
</a-tooltip>
<!-- 刷新按钮 -->
<a-tooltip content="刷新">
<a-button size="mini" class="g-hover-btn" @click="handleRefresh">
<template #icon><icon-refresh :size="18" /></template>
</a-button>
</a-tooltip>
<!-- 全屏按钮 -->
<a-tooltip content="全屏">
<a-button size="mini" class="g-hover-btn" @click="toggleFullscreen">
<template #icon>
<icon-fullscreen v-if="!isFullscreen" :size="18" />
<icon-fullscreen-exit v-else :size="18" />
</template>
</a-button>
</a-tooltip>
<!-- 边框显示按钮 -->
<a-tooltip content="显示边框">
<a-button size="mini" class="g-hover-btn" @click="toggleBorder">
<template #icon>
<Icon icon="custom:borders" :width="18" :height="18" />
</template>
</a-button>
</a-tooltip>
<!-- 表格尺寸设置 -->
<a-dropdown @select="handleSizeChange">
<a-tooltip content="表格尺寸">
<a-button size="mini" class="g-hover-btn">
<template #icon>
<Icon icon="custom:table-size" :width="17" :height="17" />
</template>
</a-button>
</a-tooltip>
<template #content>
<a-doption v-for="item in TABLE_SIZE_OPTIONS" :key="item.value" :value="item.value"
:active="item.value === size">
{{ item.label }}
</a-doption>
</template>
</a-dropdown>
<!-- 列设置按钮 -->
<a-popover v-if="showSettingColumnBtn" trigger="click" position="br"
:content-style="{ minWidth: '160px', padding: '6px 8px 10px' }">
<a-button type="primary" size="mini">
<template #icon><icon-settings /></template>
</a-button>
<template #content>
<!-- 列拖拽排序区域 -->
<div class="gi-table__draggable">
<VueDraggable :model-value="currentSettingColumns" :animation="150"
@update:model-value="(val) => { settingColumnList = val as TableSettingColumnItem[] }">
<div v-for="item in currentSettingColumns" :key="item.key" class="gi-table__draggable-item">
<div class="gi-table__draggable-item-move">
<icon-drag-dot-vertical />
</div>
<a-checkbox v-model:model-value="item.show" :disabled="item.disabled">
{{ item.title }}
</a-checkbox>
<div class="gi-table__draggable-item-fixed">
<span class="gi-table__pin-btn" :class="{ 'is-active': item.fixedLeft }"
@click.stop="toggleFixedLeft(item.key)">
<icon-pushpin />
</span>
<span class="gi-table__pin-btn gi-table__pin-btn--right" :class="{ 'is-active': item.fixedRight }"
@click.stop="toggleFixedRight(item.key)">
<icon-pushpin />
</span>
</div>
</div>
</VueDraggable>
</div>
<a-divider :margin="6" />
<a-row justify="center">
<a-button type="primary" size="mini" long @click="resetSettingColumns">
<template #icon><icon-refresh /></template>
<template #default>重置</template>
</a-button>
</a-row>
</template>
</a-popover>
</a-space>
</a-row>
<!-- 表格主体区域 -->
<div class="gi-table__container">
<a-table ref="tableRef" v-bind="tableProps" :stripe="stripe" :size="size" :bordered="{ cell: isBordered }"
:columns="visibleColumns" :data="data">
<template v-for="key in slotKeys" :key="key" #[key]="scope">
<slot :key="key" :name="key" v-bind="scope" />
</template>
</a-table>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends TableData">
import type { DropdownInstance, TableColumnData, TableData, TableInstance } from '@arco-design/web-vue'
import type { TableProps, TableSettingColumnItem } from './type'
import { Icon } from '@iconify/vue'
import { omit } from 'lodash-es'
import { computed, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
defineOptions({ name: 'GiTable' })
// Props 默认值
const props = withDefaults(defineProps<Props>(), {
title: '',
disabledColumnKeys: () => [],
data: () => []
})
/** Emits 类型定义 */
const emit = defineEmits<{
(e: 'refresh'): void
}>()
/** Slots 类型定义 */
defineSlots<{
'th': (props: { column: TableColumnData }) => void
'thead': () => void
'empty': (props: { column: TableColumnData }) => void
'summary-cell': (props: { column: TableColumnData, record: T, rowIndex: number }) => void
'pagination-right': () => void
'pagination-left': () => void
'td': (props: { column: TableColumnData, record: T, rowIndex: number }) => void
'tr': (props: { record: T, rowIndex: number }) => void
'tbody': () => void
'drag-handle-icon': () => void
'footer': () => void
'expand-row': (props: { record: T }) => void
'expand-icon': (props: { record: T, expanded?: boolean }) => void
'columns': () => void
'custom-title': () => void
'custom-extra': () => void
[propsName: string]: (props: { key: string, record: T, column: TableColumnData, rowIndex: number }) => void
}>()
/** Props 类型定义 */
interface Props extends TableProps {
/** 表格标题 */
title?: string
/** 禁止控制显示隐藏的列 */
disabledColumnKeys?: string[]
/** 表格数据 */
data?: T[]
}
const slots = useSlots()
const attrs = useAttrs()
/** 插槽键名列表(性能优化:避免模板中重复计算) */
const slotKeys = computed(() => Object.keys(slots))
/** 表格属性计算 */
const tableProps = computed(() => {
return { ...attrs, ...omit(props, ['title', 'disabledColumnKeys']) }
})
/** 组件状态 */
const tableRef = useTemplateRef('tableRef')
const stripe = ref(false)
const size = ref<TableInstance['size']>('medium')
const isBordered = ref(true)
const isFullscreen = ref(false)
/** 表格尺寸选项 */
const TABLE_SIZE_OPTIONS = [
{ label: '迷你', value: 'mini' },
{ label: '小型', value: 'small' },
{ label: '中等', value: 'medium' },
{ label: '大型', value: 'large' }
] as const
/** 处理表格尺寸变更 */
const handleSizeChange: DropdownInstance['onSelect'] = (value) => {
if (value) {
size.value = value as TableInstance['size']
}
}
/** 处理表格刷新 */
const handleRefresh = () => {
emit('refresh')
}
/** 切换全屏状态 */
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
}
/** 切换边框显示 */
const toggleBorder = () => {
isBordered.value = !isBordered.value
}
/** 列设置相关逻辑 */
const showSettingColumnBtn = computed(() => {
const columns = Array.isArray(props.columns) ? props.columns : undefined
return Boolean(columns?.length)
})
/** 获取列的 key */
const getColumnKey = (column: TableColumnData, index?: number) => {
if (column.dataIndex) return String(column.dataIndex)
if (typeof column.title === 'string' && column.title) return column.title
// 使用索引作为后备,确保唯一性
return `__column_${index ?? Date.now()}__`
}
/** 计算初始列设置列表 */
const initialSettingColumns = computed<TableSettingColumnItem[]>(() => {
if (!props.columns) return []
const columns = Array.isArray(props.columns) ? props.columns : []
return columns.map((column, index) => {
const key = getColumnKey(column, index)
const fixed = column.fixed
return {
key,
title: typeof column.title === 'string' ? column.title : '',
show: true,
disabled: props.disabledColumnKeys.includes(key),
fixedLeft: fixed === 'left',
fixedRight: fixed === 'right'
}
})
})
/** 用户修改后的列设置列表 */
const settingColumnList = ref<TableSettingColumnItem[]>([])
/** 检查列结构是否匹配 */
const isColumnStructureMatch = (userColumns: TableSettingColumnItem[], initialColumns: TableSettingColumnItem[]): boolean => {
if (userColumns.length === 0 || userColumns.length !== initialColumns.length) return false
const initialKeys = new Set(initialColumns.map((item) => item.key))
const userKeys = new Set(userColumns.map((item) => item.key))
return initialKeys.size === userKeys.size && [...initialKeys].every((key) => userKeys.has(key))
}
/** 列映射表(性能优化:避免在 visibleColumns 中重复创建) */
const columnMap = computed(() => {
if (!props.columns) return new Map<string, TableColumnData>()
const columns = Array.isArray(props.columns) ? props.columns : []
return new Map(columns.map((col, index) => [getColumnKey(col, index), col]))
})
/** 当前使用的列设置列表(自动处理列结构变化) */
const currentSettingColumns = computed(() => {
const isValid = isColumnStructureMatch(settingColumnList.value, initialSettingColumns.value)
return isValid ? settingColumnList.value : initialSettingColumns.value
})
/** 重置列设置 */
const resetSettingColumns = () => {
settingColumnList.value = [...initialSettingColumns.value]
}
/** 确保列设置列表已初始化(首次点击或拖拽前可能为空) */
const ensureSettingColumnList = () => {
if (settingColumnList.value.length === 0 && initialSettingColumns.value.length > 0) {
settingColumnList.value = initialSettingColumns.value.map((item) => ({ ...item }))
}
}
/** 切换列固定左侧:图标高亮且当前列固定到左侧 */
const toggleFixedLeft = (key: string) => {
ensureSettingColumnList()
settingColumnList.value = settingColumnList.value.map((item) =>
item.key === key
? { ...item, fixedLeft: !item.fixedLeft, fixedRight: false }
: item
)
}
/** 切换列固定右侧:图标高亮且当前列固定到右侧 */
const toggleFixedRight = (key: string) => {
ensureSettingColumnList()
settingColumnList.value = settingColumnList.value.map((item) =>
item.key === key
? { ...item, fixedRight: !item.fixedRight, fixedLeft: false }
: item
)
}
/** 监听 columns 变化,自动重置列设置 */
watch(() => props.columns, () => {
// 列结构变化时,如果用户设置不匹配,自动重置
if (!isColumnStructureMatch(settingColumnList.value, initialSettingColumns.value)) {
resetSettingColumns()
}
}, { deep: true })
/** 计算显示的列(含固定列设置):固定左 → 不固定 → 固定右 */
const visibleColumns = computed(() => {
if (!props.columns) return []
const shown = currentSettingColumns.value.filter((item) => item.show)
const leftFixed: typeof shown = []
const noFixed: typeof shown = []
const rightFixed: typeof shown = []
for (const item of shown) {
if (item.fixedLeft) leftFixed.push(item)
else if (item.fixedRight) rightFixed.push(item)
else noFixed.push(item)
}
const ordered = [...leftFixed, ...noFixed, ...rightFixed]
return ordered
.map((item) => {
const col = columnMap.value.get(item.key)
if (!col) return null
// 以列配置为准:未勾选固定时不再回退到 props 的 fixed,以便用户能取消固定
const fixed = item.fixedRight ? 'right' : item.fixedLeft ? 'left' : undefined
return { ...col, fixed }
})
.filter(Boolean) as TableColumnData[]
})
defineExpose({ tableRef })
</script>
<style lang="scss" scoped>
.gi-table {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-1);
&--fullscreen {
position: fixed;
inset: 0;
z-index: 1001;
padding: var(--padding);
}
&__container {
max-height: 100%;
overflow: hidden;
}
&__title {
font-size: 18px;
font-weight: 500;
line-height: 1.5;
color: var(--color-text-1);
}
&__draggable {
box-sizing: border-box;
max-height: 250px;
padding: 1px 0;
overflow: hidden auto;
}
&__draggable-item {
display: flex;
align-items: center;
cursor: pointer;
&:hover {
background-color: var(--color-fill-2);
}
&-move {
padding: 0 2px;
cursor: move;
}
&-fixed {
display: flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
margin-left: auto;
}
:deep(.arco-checkbox) {
flex: 1;
min-width: 0;
font-size: 12px;
.arco-checkbox-icon {
width: 14px;
height: 14px;
}
}
}
&__pin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
font-size: 14px;
color: var(--color-text-3);
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-text-2);
}
&.is-active {
color: rgb(var(--primary-6));
}
&--right {
transform: scaleX(-1);
}
}
}
</style>
@@ -1,24 +0,0 @@
import type { TableInstance as ArcoTableInstance } from '@arco-design/web-vue'
import Table from './GiTable.vue'
export type * from './type'
/**
* 组件实例类型。
*
* ⚠️ 这里**不能**写 `InstanceType<typeof Table>`。
* `GiTable.vue` 是泛型 SFC(`<script setup generic="T extends TableData">`),
* 它的类型是一个泛型函数签名而不是构造函数,`InstanceType<>` 对它不成立,
* vue-tsc 会报 `TS2344: ... does not satisfy the constraint
* 'abstract new (...args: any) => any'`。
*
* 所以按 `defineExpose({ tableRef })` 的**真实形状**手工声明:
* 暴露出来的就是那一个内层 a-table 的实例(模板 ref 在暴露时会被解包)。
* 这比 `InstanceType` 推导出来的"全部 props + slots"更准确 —— 后者本来也是假的。
*/
export interface TableInstance {
/** 内层 a-table 的实例 */
tableRef: ArcoTableInstance | null
}
export { Table as GiTable }
@@ -1,53 +0,0 @@
import type { TableInstance } from '@arco-design/web-vue'
export type TableProps = {
columns?: TableInstance['$props']['columns']
data?: TableInstance['$props']['data']
bordered?: TableInstance['$props']['bordered']
hoverable?: TableInstance['$props']['hoverable']
stripe?: TableInstance['$props']['stripe']
size?: TableInstance['$props']['size']
tableLayoutFixed?: TableInstance['$props']['tableLayoutFixed']
loading?: TableInstance['$props']['loading']
rowSelection?: TableInstance['$props']['rowSelection']
expandable?: TableInstance['$props']['expandable']
scroll?: TableInstance['$props']['scroll']
pagination?: TableInstance['$props']['pagination']
pagePosition?: TableInstance['$props']['pagePosition']
indentSize?: TableInstance['$props']['indentSize']
rowKey?: TableInstance['$props']['rowKey']
showHeader?: TableInstance['$props']['showHeader']
virtualListProps?: TableInstance['$props']['virtualListProps']
spanMethod?: TableInstance['$props']['spanMethod']
spanAll?: TableInstance['$props']['spanAll']
loadMore?: TableInstance['$props']['loadMore']
filterIconAlignLeft?: TableInstance['$props']['filterIconAlignLeft']
hideExpandButtonOnEmpty?: TableInstance['$props']['hideExpandButtonOnEmpty']
rowClass?: TableInstance['$props']['rowClass']
draggable?: TableInstance['$props']['draggable']
rowNumber?: TableInstance['$props']['rowNumber']
columnResizable?: TableInstance['$props']['columnResizable']
summary?: TableInstance['$props']['summary']
summaryText?: TableInstance['$props']['summaryText']
summarySpanMethod?: TableInstance['$props']['summarySpanMethod']
selectedKeys?: TableInstance['$props']['selectedKeys']
defaultSelectedKeys?: TableInstance['$props']['defaultSelectedKeys']
expandedKeys?: TableInstance['$props']['expandedKeys']
defaultExpandedKeys?: TableInstance['$props']['defaultExpandedKeys']
defaultExpandAllRows?: TableInstance['$props']['defaultExpandAllRows']
stickyHeader?: TableInstance['$props']['stickyHeader']
scrollbar?: TableInstance['$props']['scrollbar']
showEmptyTree?: TableInstance['$props']['showEmptyTree']
}
/** 列设置项类型 */
export interface TableSettingColumnItem {
title: string
key: string
show: boolean
disabled: boolean
/** 固定左侧 */
fixedLeft?: boolean
/** 固定右侧 */
fixedRight?: boolean
}
@@ -1,291 +0,0 @@
<template>
<div class="gi-table-setting" :class="{ 'gi-table-setting--fullscreen': isFullscreen }">
<a-row justify="space-between" align="center" class="g-row-tool">
<a-space wrap>
<slot name="custom-title">
<div class="gi-table-setting__title">{{ title }}</div>
</slot>
</a-space>
<a-space wrap>
<slot name="custom-extra" />
<a-tooltip content="导出">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="emit('export')">
<template #icon><icon-download :size="18" /></template>
</a-button>
</a-tooltip>
<a-tooltip content="斑马纹">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="stripe = !stripe">
<template #icon><icon-brush :size="18" /></template>
</a-button>
</a-tooltip>
<a-tooltip content="刷新">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="emit('refresh')">
<template #icon><icon-refresh :size="18" /></template>
</a-button>
</a-tooltip>
<a-tooltip :content="isFullscreen ? '退出全屏' : '全屏'">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="toggleFullscreen">
<template #icon>
<icon-fullscreen v-if="!isFullscreen" :size="18" />
<icon-fullscreen-exit v-else :size="18" />
</template>
</a-button>
</a-tooltip>
<a-tooltip content="显示边框">
<a-button class="gi-table-setting__btn" type="text" size="mini" @click="toggleBorder">
<template #icon><icon-apps :size="18" /></template>
</a-button>
</a-tooltip>
<a-dropdown @select="handleSizeChange">
<a-tooltip content="表格尺寸">
<a-button class="gi-table-setting__btn" type="text" size="mini">
<template #icon>
<icon-font-colors :size="18" />
</template>
</a-button>
</a-tooltip>
<template #content>
<a-doption v-for="item in TABLE_SIZE_OPTIONS" :key="item.value" :value="item.value"
:active="item.value === size">
{{ item.label }}
</a-doption>
</template>
</a-dropdown>
<a-popover v-if="showSettingColumnBtn" trigger="click" position="br"
:content-style="{ minWidth: '160px', padding: '6px 8px 10px' }">
<a-button class="gi-table-setting__btn" type="text" size="mini">
<template #icon><icon-settings :size="18" /></template>
</a-button>
<template #content>
<div class="gi-table-setting__draggable">
<VueDraggable :model-value="currentSettingColumns" :animation="150"
@update:model-value="onSettingColumnsUpdate">
<div v-for="item in currentSettingColumns" :key="item.key" class="gi-table-setting__draggable-item">
<div class="gi-table-setting__draggable-item-move">
<icon-drag-dot-vertical />
</div>
<a-checkbox v-model:model-value="item.show" :disabled="item.disabled">
{{ item.title }}
</a-checkbox>
<div class="gi-table-setting__draggable-item-fixed">
<span class="gi-table-setting__pin-btn" :class="{ 'is-active': item.fixedLeft }"
@click.stop="toggleFixedLeft(item.key)">
<icon-pushpin />
</span>
<span class="gi-table-setting__pin-btn gi-table-setting__pin-btn--right"
:class="{ 'is-active': item.fixedRight }" @click.stop="toggleFixedRight(item.key)">
<icon-pushpin />
</span>
</div>
</div>
</VueDraggable>
</div>
<a-divider :margin="6" />
<a-row justify="center">
<a-button type="primary" size="mini" long @click="resetSettingColumns">
<template #icon><icon-refresh /></template>
<template #default>重置</template>
</a-button>
</a-row>
</template>
</a-popover>
</a-space>
</a-row>
<div class="gi-table-setting__body">
<slot :setting-columns="settingColumns" :is-fullscreen="isFullscreen" :table-props="tableProps" />
</div>
</div>
</template>
<script setup lang="ts">
import type { DropdownInstance, TableColumnData, TableInstance } from '@arco-design/web-vue'
import type { GiTableSettingProps, TableSettingColumnListItem } from './type'
import { computed, ref } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { useTableColumnSetting } from './useTableColumnSetting'
defineOptions({ name: 'GiTableSetting' })
const props = withDefaults(defineProps<GiTableSettingProps>(), {
title: '',
columns: () => [],
disabledColumnKeys: () => []
})
const emit = defineEmits<{
refresh: []
export: []
}>()
defineSlots<{
'custom-title'?: () => unknown
'custom-extra'?: () => unknown
'default': (props: {
settingColumns: TableColumnData[]
isFullscreen: boolean
tableProps: {
stripe: boolean
bordered: { cell: boolean }
size: TableInstance['size']
}
}) => unknown
}>()
const stripe = ref(false)
const size = ref<TableInstance['size']>('medium')
const isBordered = ref(true)
const isFullscreen = ref(false)
const TABLE_SIZE_OPTIONS = [
{ label: '迷你', value: 'mini' },
{ label: '小型', value: 'small' },
{ label: '中等', value: 'medium' },
{ label: '大型', value: 'large' }
] as const
const tableProps = computed(() => ({
stripe: stripe.value,
bordered: { cell: isBordered.value },
size: size.value
}))
const {
showSettingColumnBtn,
currentSettingColumns,
settingColumnList,
settingColumns,
resetSettingColumns,
toggleFixedLeft,
toggleFixedRight
} = useTableColumnSetting({
columns: () => props.columns,
disabledColumnKeys: () => props.disabledColumnKeys
})
function onSettingColumnsUpdate(val: TableSettingColumnListItem[]) {
settingColumnList.value = val
}
const handleSizeChange: DropdownInstance['onSelect'] = (value) => {
if (value) {
size.value = value as TableInstance['size']
}
}
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
function toggleBorder() {
isBordered.value = !isBordered.value
}
</script>
<style lang="scss" scoped>
.gi-table-setting {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--color-bg-1);
&--fullscreen {
position: fixed;
inset: 0;
z-index: 1001;
box-sizing: border-box;
padding: var(--padding);
}
&__btn {
color: var(--color-text-1);
}
&__title {
font-size: 18px;
font-weight: 500;
line-height: 1.5;
color: var(--color-text-1);
}
&__body {
flex: 1;
min-height: 0;
overflow: hidden;
}
&__draggable {
box-sizing: border-box;
max-height: 250px;
padding: 1px 0;
overflow: hidden auto;
}
&__draggable-item {
display: flex;
align-items: center;
cursor: pointer;
&:hover {
background-color: var(--color-fill-2);
}
&-move {
padding: 0 2px;
cursor: move;
}
&-fixed {
display: flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
margin-left: auto;
}
:deep(.arco-checkbox) {
flex: 1;
min-width: 0;
font-size: 12px;
.arco-checkbox-icon {
width: 14px;
height: 14px;
}
}
}
&__pin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
font-size: 14px;
color: var(--color-text-3);
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-text-2);
}
&.is-active {
color: rgb(var(--primary-6));
}
&--right {
transform: scaleX(-1);
}
}
}
</style>
@@ -1,5 +0,0 @@
import TableSetting from './GiTableSetting.vue'
export type * from './type'
export type TableSettingInstance = InstanceType<typeof TableSetting>
export { TableSetting as GiTableSetting }
@@ -1,22 +0,0 @@
import type { TableInstance } from '@arco-design/web-vue'
export interface GiTableSettingProps {
/** 工具栏标题 */
title?: string
/** 列配置(用于列设置) */
columns?: TableInstance['columns']
/** 禁止控制显示隐藏的列 key */
disabledColumnKeys?: string[]
}
/** 列设置项类型 */
export interface TableSettingColumnListItem {
title: string
key: string
show: boolean
disabled: boolean
/** 固定左侧 */
fixedLeft?: boolean
/** 固定右侧 */
fixedRight?: boolean
}
@@ -1,141 +0,0 @@
import type { TableColumnData, TableInstance } from '@arco-design/web-vue'
import type { MaybeRefOrGetter } from 'vue'
import type { TableSettingColumnListItem } from './type'
import { computed, ref, toValue, watch } from 'vue'
export interface UseTableColumnSettingOptions {
columns: MaybeRefOrGetter<TableInstance['columns'] | undefined>
disabledColumnKeys?: MaybeRefOrGetter<string[]>
}
/** 获取列的 key */
export function getTableColumnKey(column: TableColumnData, index?: number) {
if (column.dataIndex) return String(column.dataIndex)
if (typeof column.title === 'string' && column.title) return column.title
return `__column_${index ?? Date.now()}__`
}
/** 表格列设置:显隐、排序、固定 */
export function useTableColumnSetting(options: UseTableColumnSettingOptions) {
const showSettingColumnBtn = computed(() => {
const columns = Array.isArray(toValue(options.columns)) ? toValue(options.columns) : undefined
return Boolean(columns?.length)
})
const initialSettingColumns = computed<TableSettingColumnListItem[]>(() => {
const raw = toValue(options.columns)
if (!raw) return []
const columns = Array.isArray(raw) ? raw : []
return columns.map((column, index) => {
const key = getTableColumnKey(column, index)
const fixed = column.fixed
const disabledKeys = toValue(options.disabledColumnKeys) ?? []
return {
key,
title: typeof column.title === 'string' ? column.title : '',
show: true,
disabled: disabledKeys.includes(key),
fixedLeft: fixed === 'left',
fixedRight: fixed === 'right'
}
})
})
const settingColumnList = ref<TableSettingColumnListItem[]>([])
const isColumnStructureMatch = (
userColumns: TableSettingColumnListItem[],
initialColumns: TableSettingColumnListItem[]
): boolean => {
if (userColumns.length === 0 || userColumns.length !== initialColumns.length) return false
const initialKeys = new Set(initialColumns.map((item) => item.key))
const userKeys = new Set(userColumns.map((item) => item.key))
return initialKeys.size === userKeys.size && [...initialKeys].every((key) => userKeys.has(key))
}
const columnMap = computed(() => {
const raw = toValue(options.columns)
if (!raw) return new Map<string, TableColumnData>()
const columns = Array.isArray(raw) ? raw : []
return new Map(columns.map((col, index) => [getTableColumnKey(col, index), col]))
})
const currentSettingColumns = computed(() => {
const isValid = isColumnStructureMatch(settingColumnList.value, initialSettingColumns.value)
return isValid ? settingColumnList.value : initialSettingColumns.value
})
const resetSettingColumns = () => {
settingColumnList.value = [...initialSettingColumns.value]
}
const ensureSettingColumnList = () => {
if (settingColumnList.value.length === 0 && initialSettingColumns.value.length > 0) {
settingColumnList.value = initialSettingColumns.value.map((item) => ({ ...item }))
}
}
const toggleFixedLeft = (key: string) => {
ensureSettingColumnList()
settingColumnList.value = settingColumnList.value.map((item) =>
item.key === key
? { ...item, fixedLeft: !item.fixedLeft, fixedRight: false }
: item
)
}
const toggleFixedRight = (key: string) => {
ensureSettingColumnList()
settingColumnList.value = settingColumnList.value.map((item) =>
item.key === key
? { ...item, fixedRight: !item.fixedRight, fixedLeft: false }
: item
)
}
watch(
() => toValue(options.columns),
() => {
if (!isColumnStructureMatch(settingColumnList.value, initialSettingColumns.value)) {
resetSettingColumns()
}
},
{ deep: true }
)
const settingColumns = computed(() => {
const raw = toValue(options.columns)
if (!raw) return []
const shown = currentSettingColumns.value.filter((item) => item.show)
const leftFixed: typeof shown = []
const noFixed: typeof shown = []
const rightFixed: typeof shown = []
for (const item of shown) {
if (item.fixedLeft) leftFixed.push(item)
else if (item.fixedRight) rightFixed.push(item)
else noFixed.push(item)
}
const ordered = [...leftFixed, ...noFixed, ...rightFixed]
return ordered
.map((item) => {
const col = columnMap.value.get(item.key)
if (!col) return null
const fixed = item.fixedRight ? 'right' : item.fixedLeft ? 'left' : undefined
return { ...col, fixed }
})
.filter(Boolean) as TableColumnData[]
})
return {
showSettingColumnBtn,
currentSettingColumns,
settingColumnList,
settingColumns,
resetSettingColumns,
toggleFixedLeft,
toggleFixedRight
}
}
-135
View File
@@ -1,135 +0,0 @@
/**
* @file GiTag 组件 - 提供多样化的标签展示
* @description 支持多种样式类型、状态、颜色和大小的标签组件
*/
import type { CSSProperties, PropType } from 'vue'
import type { TagProps as Props } from './type'
import { computed, defineComponent } from 'vue'
import './tag.scss'
/** 基础颜色映射表 */
const BASE_COLORS: Record<string, string> = {
red: '#FF0000',
orangered: '#f77234',
orange: '#ff7d00',
gold: '#f7ba1e',
lime: '#9fdb1d',
green: '#00b42a',
cyan: '#14c9c9',
blue: '#3491fa',
purple: '#722ed1',
pink: '#f5319d',
gray: '#86909c'
} as const
export default defineComponent({
name: 'GiTag',
props: {
type: {
type: String as PropType<Props['type']>,
default: 'light'
},
status: {
type: String as PropType<Props['status']>,
default: 'primary'
},
color: {
type: String as PropType<Props['color'] | string>,
default: ''
},
size: {
type: String as PropType<Props['size']>,
default: 'small'
},
closable: {
type: Boolean,
default: false
}
},
emits: ['click', 'close'],
setup(props: Props, { slots, emit }) {
/**
* 将十六进制颜色转换为 RGB 对象
* @param hex - 十六进制颜色值
*/
const hexToRgb = (hex: string) => {
const cleanHex = hex.startsWith('#') ? hex.slice(1) : hex
return {
r: Number.parseInt(cleanHex.slice(0, 2), 16),
g: Number.parseInt(cleanHex.slice(2, 4), 16),
b: Number.parseInt(cleanHex.slice(4, 6), 16)
}
}
/** 计算标签的 class */
const className = computed(() => {
return [
'gi-tag',
props.type && `gi-tag__type--${props.type}`,
props.size && `gi-tag__size--${props.size}`,
props.status && `gi-tag__status--${props.status}`
].filter(Boolean)
})
/** 计算标签的样式 */
const calcStyle = computed((): CSSProperties => {
if (!props.color) return {}
const color = BASE_COLORS[props.color as string] || props.color
const { r, g, b } = hexToRgb(color)
const style: CSSProperties = {}
switch (props.type) {
case 'light':
style.color = color
style.backgroundColor = `rgba(${r}, ${g}, ${b}, 0.1)`
style['--tag-close-hover-bg-color'] = color
break
case 'dark':
style.color = '#fff'
style.backgroundColor = color
style['--tag-close-hover-color'] = color
style['--tag-close-hover-bg-color'] = 'rgba(255, 255, 255, 0.9)'
break
case 'outline':
style.color = color
style.backgroundColor = 'transparent'
style.borderColor = color
style['--tag-close-hover-bg-color'] = color
break
case 'light-outline':
style.color = color
style.backgroundColor = `rgba(${r}, ${g}, ${b}, 0.1)`
style.borderColor = `rgba(${r}, ${g}, ${b}, 0.2)`
style['--tag-close-hover-bg-color'] = color
break
}
return style
})
/** 点击标签事件处理 */
const handleClick = () => emit('click')
/** 关闭标签事件处理 */
const handleClose = (event: MouseEvent) => {
event.stopPropagation()
emit('close')
}
/** 关闭图标组件 */
const CloseIcon = props.closable && (
<span class="gi-tag-close-btn" onClick={handleClose}>
<icon-close class="close-icon" />
</span>
)
return () => (
<span class={className.value} style={calcStyle.value} onClick={handleClick}>
{slots.default?.()}
{CloseIcon}
</span>
)
}
})
@@ -1,5 +0,0 @@
import Tag from './GiTag.tsx'
export type TagInstance = InstanceType<typeof Tag>
export * from './type'
export { Tag as GiTag }
-166
View File
@@ -1,166 +0,0 @@
$status: primary, success, warning, danger;
$tag-size-mini-height: 20px;
$tag-size-small-height: 22px;
$tag-size-large-height: 24px;
$tag-size-mini-padding: 0 6px;
$tag-size-small-padding: 0 8px;
$tag-size-large-padding: 0 10px;
.gi-tag {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
height: $tag-size-small-height;
padding: $tag-size-small-padding;
font-size: 12px;
line-height: 1;
white-space: nowrap;
cursor: pointer;
border-radius: 3px;
}
.gi-tag-close-btn {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 4px;
cursor: pointer;
background-color: transparent;
border-radius: var(--border-radius-circle);
transition: background-color 0.1s cubic-bezier(0, 0, 1, 1);
.close-icon {
z-index: 9;
width: 12px;
height: 12px;
}
}
.gi-tag__size--mini {
height: $tag-size-mini-height;
padding: $tag-size-mini-padding;
.gi-tag-close-btn {
.close-icon {
width: 10px;
height: 10px;
}
&::before {
width: 14px;
height: 14px;
}
}
}
.gi-tag__size--small {
height: $tag-size-small-height;
padding: $tag-size-small-padding;
}
.gi-tag__size--large {
height: $tag-size-large-height;
padding: $tag-size-small-padding;
}
.gi-tag__type--light {
color: #fff;
@each $i in $status {
&.gi-tag__status--#{$i}
{
color: rgb(var(--#{$i}-6));
background-color: rgb(var(--#{$i}-1));
--tag-close-hover-color: #fff;
--tag-close-hover-bg-color: rgb(var(--#{$i}-6));
.gi-tag-close-btn {
&:hover {
color: var(--tag-close-hover-color);
background-color: var(--tag-close-hover-bg-color);
}
}
}
}
}
.gi-tag__type--dark {
color: #fff;
@each $i in $status {
&.gi-tag__status--#{$i}
{
background-color: rgb(var(--#{$i}-6));
--tag-close-hover-color: rgb(var(--#{$i}-6));
--tag-close-hover-bg-color: rgb(255 255 255 / 90%);
.gi-tag-close-btn {
&:hover {
color: var(--tag-close-hover-color);
background-color: var(--tag-close-hover-bg-color);
}
}
}
}
}
.gi-tag__type--outline {
background: transparent;
border-style: solid;
border-width: 1px;
@each $i in $status {
&.gi-tag__status--#{$i}
{
color: rgb(var(--#{$i}-6));
border-color: rgb(var(--#{$i}-6));
--tag-close-hover-color: #fff;
--tag-close-hover-bg-color: rgb(var(--#{$i}-6));
.gi-tag-close-btn {
&:hover {
color: var(--tag-close-hover-color);
background-color: var(--tag-close-hover-bg-color);
}
}
}
}
}
.gi-tag__type--light-outline {
border-style: solid;
border-width: 1px;
@each $i in $status {
&.gi-tag__status--#{$i}
{
color: rgb(var(--#{$i}-6));
background-color: rgb(var(--#{$i}-1));
border-color: rgb(var(--#{$i}-2));
--tag-close-hover-color: #fff;
--tag-close-hover-bg-color: rgb(var(--#{$i}-6));
.gi-tag-close-btn {
&:hover {
color: var(--tag-close-hover-color);
background-color: var(--tag-close-hover-bg-color);
}
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
/** 组件属性定义 */
export interface TagProps {
/** 标签类型 */
type?: 'dark' | 'light' | 'outline' | 'light-outline'
/** 标签状态 */
status?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
/** 自定义颜色,支持预设颜色名或十六进制颜色值 */
color?: 'red' | 'orangered' | 'orange' | 'gold' | 'lime' | 'green' | 'cyan' | 'blue' | 'purple' | 'pink' | 'gray' | string
/** 标签尺寸 */
size?: 'mini' | 'small' | 'large'
/** 是否可关闭 */
closable?: boolean
}
@@ -1,74 +0,0 @@
<template>
<a-select
v-model="model"
class="gi-tenant-picker"
:placeholder="props.placeholder"
:loading="loading"
:disabled="props.disabled"
:style="{ width: props.width }"
allow-search
allow-clear
>
<a-option v-for="t in options" :key="t.id" :value="t.id" :disabled="t.status === 0">
{{ t.tenantName }}({{ t.tenantCode }}){{ t.status === 0 ? ' · 已停用' : '' }}
</a-option>
</a-select>
</template>
<script setup lang="ts">
import type { TenantVO } from '@/apis/xt/tenant'
import { getTenantList } from '@/apis/xt/tenant'
/**
* 「选择学校」——平台运营的当前租户。
*
* 为什么要有这个东西:平台运营是跨租户身份,服务端对它的租户过滤是**关掉的**,
* 所以它必须**先指明看哪所学校**,否则「组织架构」「账号管理」会拿到所有租户混在一起的数据。
* 选中的值由页面带进请求头 `X-Tenant-Id`(见 `@/apis/xt/acting-tenant`)。
*
* ⚠️ 只有平台运营的页面才渲染它 —— 学校管理员的租户由 token 决定,给他一个
* "选择学校"的下拉框是纯粹的困惑(而且服务端会忽略他传的值)。
*
* ⚠️ 租户列表接口只对平台运营开放(学校管理员调它得 40301),所以本组件
* **不要**在非平台页面里渲染。
*/
defineOptions({ name: 'GiTenantPicker' })
const model = defineModel<number | undefined>({ default: undefined })
const props = withDefaults(defineProps<{
placeholder?: string
disabled?: boolean
width?: string
}>(), {
placeholder: '选择学校',
disabled: false,
width: '100%'
})
const loading = ref(false)
const options = ref<TenantVO[]>([])
async function load() {
loading.value = true
try {
// 一期租户规模是"几十所"量级(接口上限 200),一次取完比做分页下拉简单得多。
// 已停用的也列出来但不可选 —— 服务端会拒("目标单位不存在或已停用"),
// 与其让人选完才知道,不如在选择时就说明。
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
options.value = res.data?.list ?? []
} finally {
loading.value = false
}
}
onMounted(load)
defineExpose({ reload: load })
</script>
<style lang="scss" scoped>
.gi-tenant-picker {
min-width: 180px;
}
</style>
@@ -1,16 +0,0 @@
<template>
<a-button size="mini" class="g-hover-btn" @click="toggleTheme">
<template #icon>
<icon-sun-fill v-if="!isDark" :size="18" />
<icon-moon-fill v-else :size="18" />
</template>
</a-button>
</template>
<script setup lang="ts">
import { useTheme } from '@/hooks'
defineOptions({ name: 'GiThemeBtn' })
const { isDark, toggleTheme } = useTheme()
</script>
@@ -1,4 +0,0 @@
import ThemeBtn from './GiThemeBtn.vue'
export type ThemeBtnInstance = InstanceType<typeof ThemeBtn>
export { ThemeBtn as GiThemeBtn }
@@ -1,45 +0,0 @@
<template>
<a-spin :loading="loading" class="g-full-column">
<a-input-search class="g-mb" placeholder="请输入关键词" allow-clear />
<a-scrollbar style="height: 100%; overflow: auto" outer-style="flex: 1;overflow: hidden">
<a-tree ref="treeRef" show-line size="mini" :data="(treeData as unknown as TreeNodeData[])"
:field-names="{ key: 'id', title: 'name' }" @select="select">
</a-tree>
</a-scrollbar>
</a-spin>
</template>
<script lang="ts" setup>
import type { TreeNodeData } from '@arco-design/web-vue'
import { getCateTree } from '@/apis/cate'
const emit = defineEmits<{
(e: 'node-click'): void
}>()
const treeRef = useTemplateRef('treeRef')
const loading = ref(false)
const treeData = ref<any[]>([])
// 获取分类树
const getTreeData = async () => {
try {
loading.value = true
const res = await getCateTree()
treeData.value = res.data
nextTick(() => {
treeRef.value?.expandAll()
})
} finally {
loading.value = false
}
}
getTreeData()
const select = () => {
emit('node-click')
}
</script>
<style lang="scss" scoped></style>
@@ -1,93 +0,0 @@
<template>
<GiPageLayout bg-transparent>
<template #left>
<Tree @node-click="search" />
</template>
<a-row justify="end" class="g-row-tool">
<a-space wrap>
<a-input v-model="searchParams.username" placeholder="输入用户名搜索" allow-clear style="max-width: 250px">
</a-input>
<a-button type="primary" @click="search">
<template #icon><icon-search /></template>
<span>查询</span>
</a-button>
<a-button @click="reset">重置</a-button>
</a-space>
</a-row>
<a-table class="g-table" row-key="id" :loading="loading" :bordered="{ cell: true }" :columns="columns"
:data="tableData" :scroll="{ x: '100%', y: '100%' }"
:row-selection="{ type: props.multiple ? 'checkbox' : 'radio', showCheckedAll: true }" :pagination="pagination"
@select="select" @select-all="selectAll">
</a-table>
</GiPageLayout>
</template>
<script setup lang="tsx">
import type { TableColumnData } from '@arco-design/web-vue'
import { baseAPI } from '@/apis/person'
import { GiCellStatus } from '@/components/index'
import { useTable } from '@/hooks'
import Tree from './Tree.vue'
interface Props {
multiple?: boolean
queryParams?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
queryParams: () => ({})
})
const searchParams = reactive({
username: ''
})
const columns = [
{
title: '序号',
width: 68,
align: 'center',
render: ({ rowIndex }) => (<span>{rowIndex + 1}</span>)
},
{
title: '姓名',
dataIndex: 'name',
width: 100
},
{
title: '创建时间',
dataIndex: 'createTime',
width: 180
},
{
title: '地址',
dataIndex: 'address',
ellipsis: true,
tooltip: true
},
{
title: '状态',
width: 100,
align: 'center',
render: ({ record }) => {
return <GiCellStatus status={record.status} />
}
}
] as TableColumnData[]
const { loading, tableData, pagination, search, select, selectAll, getSelectedData } = useTable({
listAPI: (page) => baseAPI.getList({ ...page, ...searchParams, ...props.queryParams }),
immediate: true
})
const reset = () => {
searchParams.username = ''
search()
}
defineExpose({ getSelectedData })
</script>
<style lang="scss" scoped></style>
@@ -1,89 +0,0 @@
<template>
<GiPageLayout bg-transparent>
<a-row justify="end" class="g-row-tool">
<a-space wrap>
<a-input v-model="searchParams.username" placeholder="输入用户名搜索" allow-clear style="max-width: 250px">
</a-input>
<a-button type="primary" @click="search">
<template #icon><icon-search /></template>
<span>查询</span>
</a-button>
<a-button @click="reset">重置</a-button>
</a-space>
</a-row>
<a-table class="g-table" row-key="id" :loading="loading" :bordered="{ cell: true }" :columns="columns"
:data="tableData" :scroll="{ x: '100%', y: '100%' }"
:row-selection="{ type: props.multiple ? 'checkbox' : 'radio', showCheckedAll: true }" :pagination="pagination"
@select="select" @select-all="selectAll">
</a-table>
</GiPageLayout>
</template>
<script setup lang="tsx">
import type { TableColumnData } from '@arco-design/web-vue'
import { baseAPI } from '@/apis/person'
import { GiCellStatus } from '@/components/index'
import { useTable } from '@/hooks'
interface Props {
multiple?: boolean
queryParams?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
queryParams: () => ({})
})
const searchParams = reactive({
username: ''
})
const columns = [
{
title: '序号',
width: 68,
align: 'center',
render: ({ rowIndex }) => (<span>{rowIndex + 1}</span>)
},
{
title: '姓名',
dataIndex: 'name',
width: 180
},
{
title: '创建时间',
dataIndex: 'createTime',
width: 180
},
{
title: '地址',
dataIndex: 'address',
ellipsis: true,
tooltip: true
},
{
title: '状态',
width: 100,
align: 'center',
render: ({ record }) => {
return <GiCellStatus status={record.status} />
}
}
] as TableColumnData[]
const { loading, tableData, pagination, search, select, selectAll, getSelectedData } = useTable({
listAPI: (page) => baseAPI.getList({ ...page, ...searchParams, ...props.queryParams }),
immediate: true
})
const reset = () => {
searchParams.username = ''
search()
}
defineExpose({ getSelectedData })
</script>
<style lang="scss" scoped></style>

Some files were not shown because too many files have changed in this diff Show More