feat(frontend): 管理端改用 Gi Admin Pro 框架 + 腾讯云主题

前端此前只有本地实现、从未入库,本次首次纳入版本管理。

框架切换
  · 以 Gi Admin Pro(Vue3 + Vite + TS + Arco Design Vue + Pinia)为底
  · 剥离 14 个演示页面目录 / 121 个文件(demo 28、file 20、form 19、system 18…)
  · 依赖 dependencies 35 → 19,devDependencies 31 → 27
  · 保留框架核心:layout、Gi* 组件库、hooks、权限指令、错误页、登录页

主题:腾讯云 / TDesign 官方色板
  · 主色 #0052D9(腾讯蓝)、成功 #00A870、警告 #ED7B2F、危险 #E34D59
  · 页面底色 #f3f3f3、文字 rgba(0,0,0,90/60/40/26%)
  · 覆盖 Arco 底层色板 + 显式语义别名(primary/success/warning/danger/link/neutral)
  · 三个改了不生效也不报错的坑已记入注释:
      1) Arco 色值是逗号分隔 RGB 三元组,不是 #RRGGBB
      2) Arco 把色板定义在 body 而非 :root,必须写 `:root, body`
      3) setting.json 的 themeColor 会以内联样式写进 body,优先级高于样式表

对接后端(三处契约差异在 @/utils/http 统一适配)
  · 响应信封 {code,msg,data} → {code,success,message,data}
  · Token 头 token → Authorization: Bearer
  · 分页 {list,total} → 补一份 records,list 原样保留
  · 40101 → 登录页;登录密码 RSA 加密(公钥走 /auth/public-key,轮换无需重建前端)
  · 路由:后端只下发菜单键,前端在本地路由表上过滤(有哪些页面归代码,谁能看归后端)

已实现页面(浏览器实测通过)
  · 登录:RSA 加密 + 自适应验证码/租户码 + 账号锁定提示
  · 工作台:真实数据(组织 6 个 / 角色 1 个 / 权限 25 项)
  · 组织架构:三级树 + CRUD + 占用校验
  · 租户管理:开通租户 + 初始密码只显示一次的强确认
  · 待建页:未实现菜单显示所属里程碑
  · 权限菜单:学校管理员看不到「租户管理」

视觉资产按腾讯云主题重做
  · logo.svg(渐变圆角方 + 填)、favicon.svg(小尺寸做减法)、login-hero.svg(材料归集)
  · 登录页点阵纹理走主题变量,换主色自动跟随
  · 文案改为产品实际能力:自动改名 / 强制压缩 / 隐私保护

清理模板遗留(约 17MB + 三处外部依赖)
  · 百度统计脚本(上报到 gi-demo 作者的统计账号)
  · 用户菜单「项目地址」(跳转模板作者的 Gitee 仓库)
  · 两份 8.8MB mp3、388KB favicon.ico、57KB 模板插画、未引用字体与 loading.gif
  · 「个人中心」指向已删除的 /system/account(点了 404)
  · 头部头像在无 avatarUrl 时渲染成裂图 → 改为姓名首字兜底

后端修复
  · 新增 GET /auth/public-key(前端加密登录密码用)
  · DataSeeder.ancestor_path 生成错误:子节点本该是 /16/17/ 却写成 /17/
    该字段是「按院系统计」的索引,错了会漏人/多人且不报错 —— 改为从库里读父节点路径
  · seedDemoOrgs 曾放在租户作用域外 + admin 早退分支之后,导致启动失败 / 永不执行
  · 示范租户补一套三级组织树(1 学校 / 2 院系 / 3 班级),供界面演示
This commit is contained in:
2026-09-28 20:49:55 +08:00
parent 9d7a8ca845
commit 87ece5a971
237 changed files with 25215 additions and 26 deletions
@@ -4,7 +4,10 @@ import javax.crypto.Cipher;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.KeyFactory; import java.security.KeyFactory;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64; import java.util.Base64;
/** /**
@@ -40,6 +43,32 @@ public final class RsaCipher {
} }
} }
/**
* 导出对应的公钥(PEM,X.509/SPKI)。
*
* <p>用于 {@code GET /api/v1/auth/public-key}:前端启动时拉一次,
* 之后用它加密登录密码。
*
* <p><b>为什么不把公钥打包进前端</b>:一是密钥轮换时前端要重新构建、重新发布;
* 二是打包进去意味着"改密钥"这件事横跨前后端两个仓库,
* 实际操作中一定会出现"后端换了、前端没换"导致全员登不进去。
* 公钥是公开信息,放接口上没有任何安全损失。
*/
public String publicKeyPem() {
try {
if (!(privateKey instanceof RSAPrivateCrtKey crt)) {
throw new IllegalStateException("私钥不是 CRT 格式,无法推导公钥");
}
RSAPublicKey pub = (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new RSAPublicKeySpec(crt.getModulus(), crt.getPublicExponent()));
String b64 = Base64.getMimeEncoder(64, new byte[]{'\n'})
.encodeToString(pub.getEncoded());
return "-----BEGIN PUBLIC KEY-----\n" + b64 + "\n-----END PUBLIC KEY-----\n";
} catch (Exception e) {
throw new IllegalStateException("导出 RSA 公钥失败", e);
}
}
/** /**
* 解密前端传来的密码。 * 解密前端传来的密码。
* *
@@ -1,6 +1,7 @@
package cn.tianbaotong.fill.module.auth.controller; package cn.tianbaotong.fill.module.auth.controller;
import cn.tianbaotong.fill.common.api.ApiResult; import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.crypto.RsaCipher;
import cn.tianbaotong.fill.module.auth.dto.LoginReq; import cn.tianbaotong.fill.module.auth.dto.LoginReq;
import cn.tianbaotong.fill.module.auth.dto.LoginVO; import cn.tianbaotong.fill.module.auth.dto.LoginVO;
import cn.tianbaotong.fill.module.auth.service.AuthService; import cn.tianbaotong.fill.module.auth.service.AuthService;
@@ -29,6 +30,7 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final CaptchaService captchaService; private final CaptchaService captchaService;
private final RsaCipher rsaCipher;
@PublicApi @PublicApi
@PostMapping("/login") @PostMapping("/login")
@@ -44,6 +46,20 @@ public class AuthController {
return ApiResult.ok(captchaService.generate()); return ApiResult.ok(captchaService.generate());
} }
/**
* 登录密码加密用的公钥。
*
* <p>公钥是公开信息,无需鉴权。前端启动时拉一次并缓存,
* 这样<b>轮换密钥不需要重新构建前端</b>——否则"后端换了、前端没换"
* 会表现为"所有人密码都错",而且很难定位。
*/
@PublicApi
@GetMapping("/public-key")
@Operation(summary = "获取登录加密公钥", description = "PEM 格式,前端用它与 RSA/ECB/PKCS1Padding 加密密码")
public ApiResult<String> publicKey() {
return ApiResult.ok(rsaCipher.publicKeyPem());
}
@GetMapping("/me") @GetMapping("/me")
@Operation(summary = "当前用户", description = "返回用户、租户、角色、菜单与细粒度权限码") @Operation(summary = "当前用户", description = "返回用户、租户、角色、菜单与细粒度权限码")
public ApiResult<LoginVO> me() { public ApiResult<LoginVO> me() {
@@ -2,6 +2,8 @@ package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.context.LoginUser; import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext; import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.module.org.entity.SysOrg;
import cn.tianbaotong.fill.module.org.mapper.OrgMapper;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant; import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper; import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser; import cn.tianbaotong.fill.module.user.entity.SysUser;
@@ -44,6 +46,7 @@ public class DataSeeder implements ApplicationRunner {
private final TenantMapper tenantMapper; private final TenantMapper tenantMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final OrgMapper orgMapper;
private final RoleProvisioner roleProvisioner; private final RoleProvisioner roleProvisioner;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@@ -102,6 +105,15 @@ public class DataSeeder implements ApplicationRunner {
TenantScope.runAs(tid, 0L, Set.of(BuiltinRole.SCHOOL_ADMIN.getCode()), () -> { TenantScope.runAs(tid, 0L, Set.of(BuiltinRole.SCHOOL_ADMIN.getCode()), () -> {
roleProvisioner.provisionBuiltinRoles(tid); roleProvisioner.provisionBuiltinRoles(tid);
// ⚠️ 必须放在 runAs 内部:sys_org 有 tenant_id,
// 在租户作用域外查询会触发拦截器的「默认拒绝」直接抛 40101,
// 而且它发生在启动阶段(ApplicationRunner),表现是应用起不来。
//
// ⚠️ 也必须放在下面那个「admin 已存在就早退」之前:
// 否则第二次启动之后组织树永远不会被补上(自身已幂等,重复调用无害)。
seedDemoOrgs(tid);
if (findUser(tid, "admin") != null) { if (findUser(tid, "admin") != null) {
return; return;
} }
@@ -118,16 +130,76 @@ public class DataSeeder implements ApplicationRunner {
}); });
} }
/**
* 示范组织树。
*
* <p>空的组织树看不出系统在做什么,所以给个有真实感的三级结构
* (学校 → 院系 → 班级)。名字取自客户的真实班级命名习惯(24中技数控1班)。
*
* <p>幂等:只要该租户已有任何组织就跳过,不覆盖用户自己建的。
*/
private void seedDemoOrgs(final long tenantId) {
Long existing = orgMapper.selectCount(Wrappers.<SysOrg>lambdaQuery());
if (existing != null && existing > 0) {
return;
}
TenantScope.runAs(tenantId, 0L, Set.of(BuiltinRole.SCHOOL_ADMIN.getCode()), () -> {
long school = insertOrg(0L, "示范职业学校", 1, "DEMO", 1);
long mech = insertOrg(school, "智能制造系", 2, "MECH", 1);
insertOrg(mech, "24中技数控1班", 3, "24ZK1", 1);
insertOrg(mech, "24中技机电1班", 3, "24JD1", 2);
long cs = insertOrg(school, "信息工程系", 2, "CS", 2);
insertOrg(cs, "24中技计算机1班", 3, "24JSJ1", 1);
log.info("已为示范租户创建组织树(1 个学校 / 2 个院系 / 3 个班级)");
});
}
/**
* 插入一个组织节点并回填 {@code ancestor_path}。
*
* <p>⚠️ 路径形如 {@code /10/11/12/},**必须包含全部祖先**,末位才是自己。
* 这不是显示用的字段 —— 「按院系统计」靠 {@code LIKE '/10/%'} 一次覆盖所有后代,
* 路径错了统计就会漏人/多人,而且不会报错。
*
* <p>路径依赖自身 id,只能先插入再回写;上级路径从库里查(不靠入参传),
* 避免调用方漏传或传错 —— 之前就是因为把 parentPath 传成 null,
* 导致所有子节点的路径都退化成了只有自己。
*/
private long insertOrg(Long parentId, String name, int type, String code, int sortNo) {
SysOrg o = new SysOrg();
o.setParentId(parentId);
o.setOrgName(name);
o.setOrgType(type);
o.setOrgCode(code);
o.setSortNo(sortNo);
o.setStatus(1);
orgMapper.insert(o);
String parentPath = "/";
if (parentId != null && parentId != 0L) {
SysOrg parent = orgMapper.selectById(parentId);
if (parent == null || parent.getAncestorPath() == null) {
throw new IllegalStateException("上级组织的层级路径缺失:" + parentId);
}
parentPath = parent.getAncestorPath();
}
String path = parentPath + o.getId() + "/";
SysOrg patch = new SysOrg();
patch.setId(o.getId());
patch.setAncestorPath(path);
orgMapper.updateById(patch);
return o.getId();
}
private SysUser findUser(long tenantId, String username) { private SysUser findUser(long tenantId, String username) {
return userMapper.selectOne(Wrappers.<SysUser>lambdaQuery() return userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getTenantId, tenantId) .eq(SysUser::getTenantId, tenantId)
.eq(SysUser::getUsername, username) .eq(SysUser::getUsername, username)
.last("LIMIT 1")); .last("LIMIT 1"));
} }
/** 保留:主应用启动时用到的常量,避免未使用告警。 */
@SuppressWarnings("unused")
private static List<Long> noop() {
return List.of();
}
} }
+29
View File
@@ -0,0 +1,29 @@
# .agents/
本目录为 AI 编码助手规范的**唯一来源**(Single Source of Truth),适用于 Cursor、OpenCode、Codex 等工具。
## 结构
```
.agents/
├── rules/
│ └── agent.md # 唯一常驻规则(Cursor 经 AGENTS.md 内联注入)
└── skills/ # 按需加载
├── code-ts/SKILL.md
├── code-vue/SKILL.md
├── code-css/SKILL.md
├── project-guide/SKILL.md
├── project-crud/SKILL.md
├── project-api/SKILL.md
└── git-commit/SKILL.md
```
## 工具适配
| 工具 | 适配方式 |
|------|----------|
| Cursor | `AGENTS.md`(内联 behavior 规则 + 技能索引)+ `.agents/skills/`(按需) |
| OpenCode | `AGENTS.md` + `opencode.json` + `.agents/rules/` + `.agents/skills/` |
| Codex | `AGENTS.md` + `.agents/skills/` |
修改 `agent.md` 后请同步 `AGENTS.md` 的「常驻规则」一节;编码规范改对应 skill,不要重复写入 `AGENTS.md`。
+36
View File
@@ -0,0 +1,36 @@
# Agent 行为准则
> 适用于 Cursor、OpenCode、Codex 等 AI 编码助手。Cursor 经 `AGENTS.md` 内联本节每轮注入。
## 通用要求
- 始终使用**简体中文**与用户沟通。
- 未经用户明确要求,**不要**创建说明文档、执行 git commit/push、修改 git 配置。
- 改动范围最小化:只改与任务直接相关的代码,不顺手重构无关模块。
- 优先复用项目已有抽象与约定,禁止凭空引入新架构或重复造轮子。
## 应当
- 改代码前先阅读项目既有实现,保持风格一致。
- 优先复用项目内已有组件、composables、工具函数与 API 封装。
- 任务完成后,视情况运行 lint / typecheck / test(与改动相关时)。
- **按需读取** skills,不要预加载全部;写代码前按任务类型加载对应 skill(见 `AGENTS.md` 技能索引)。
## 不应
- 为已自动导入的 API / 组件写冗余 import。
- 引入与项目技术栈不符的 UI 库或全新抽象层。
- 猜测不存在的 API 路径、响应结构或配置项;以项目代码与类型定义为准。
- 在用户未要求时添加测试、文档或无关注释。
## 编码前必读 skill
| 任务 | 技能 |
|------|------|
| 写/改 `.vue` | `code-vue` |
| 写/改样式 | `code-css` |
| 写/改 `.ts` / composable | `code-ts` |
| 项目约定、Gi 组件 | `project-guide` |
| CRUD 列表/表单 | `project-crud` |
| API / Mock | `project-api` |
| 提交代码 | `git-commit` |
@@ -0,0 +1,36 @@
---
name: code-css
description: Vue SFC 样式块、BEM 命名、Stylelint 与 scoped 约定。编写或修改组件样式(.vue style、scss、less、css)时加载本技能。
---
# CSS 规范(Vue 3)
## SFC 样式块
- 位于 `<script>` 之后;默认 **scoped**;全局样式须在注释中说明原因。
- 预处理器按项目约定,同一组件内保持一致。
## 命名(BEM)
- 自定义 class:**kebab-case** + **BEM**(`block`、`block__element`、`block--modifier`)。
- 一个 SFC 通常一个 Block;元素用 `__`,修饰符用 `--`;**禁止** `block__a__b` 链式元素。
- 第三方 UI 类名、全局工具类不在 BEM 约束内。
```scss
.user-card {
&__avatar { width: 48px; }
&__badge--vip { color: var(--color-warning); }
}
```
## 编写原则
- 颜色、间距、字号优先复用项目 CSS 变量或设计令牌。
- 避免内联 `style`(动态样式除外)和滥用 `!important`。
- 遵循项目 Stylelint;改动后视情况运行 `lint:style`。
## 禁止
- ❌ 自定义 class 用 camelCase 或非语义命名
- ❌ BEM 元素链式嵌套(`block__a__b`)
- ❌ 大段内联 style 或过多 `:deep()` 堆叠
@@ -0,0 +1,38 @@
---
name: code-ts
description: Vue 3 + TypeScript 命名、导入顺序、目录组织与 Lint 约定。编写或修改 .ts/.js 代码、新增 composable 或工具函数时加载本技能。
---
# TypeScript 规范(Vue 3)
Vue SFC 见 [code-vue](../code-vue/SKILL.md),样式见 [code-css](../code-css/SKILL.md)。
## 命名
- 组件 / 文件名:**PascalCase**;变量 / 函数:**camelCase**(事件处理推荐 `handle` 前缀)。
- 常量:需要时 **UPPER_SNAKE_CASE**;Composables:`use` 前缀。
- Props camelCase;模板事件名与 emits 定义 **kebab-case**。
- 样式 class:**kebab-case** + BEM(见 [code-css](../code-css/SKILL.md));全局组件前缀遵循项目统一约定。
## TypeScript
- `import type` 与值导入分离;对象结构优先 `interface`,联合/工具类型用 `type`。
- Props 默认值:`withDefaults(defineProps<Type>(), { ... })`;避免 `any`。
- 公共类型集中管理;领域类型靠近业务模块。
## 导入顺序
1. 类型导入 → 2. 第三方 → 3. Vue/官方库 → 4. 项目内部(composables、组件、utils、样式)
不要重复 import 已自动注册的符号(类型除外)。
## 组织
- 遵循项目现有目录划分;单文件建议不超过 400 行,超出应拆分。
- 可复用 UI 放组件目录;跨页面逻辑抽 composables;纯函数抽 utils。
- 路径别名遵循项目配置,避免 `../../../` 穿越。
## Lint
- 遵循项目 ESLint、Stylelint、tsconfig,不擅自关闭规则。
- 必要的 `eslint-disable` / `@ts-expect-error` 须注释说明原因。
@@ -0,0 +1,67 @@
---
name: code-vue
description: Vue 3 Composition API、SFC 块顺序、defineEmits、useTemplateRef、响应式约定。编写或修改 .vue 文件时加载本技能。
---
# Vue 规范(Vue 3)
适用于 Composition API + `<script setup lang="ts">`。样式见 [code-css](../code-css/SKILL.md)。
## 基础
- 新组件一律 **Composition API** + `<script setup>`,禁止 Options API。
- 需要组件名时用 `defineOptions({ name: 'ComponentName' })`;路由页 `name` 与路由配置一致。
- 块顺序:`<template>` → `<script setup>` → `<style>`(遵循项目 ESLint block-order)。
## Script 内顺序
类型导入 → 第三方 → Vue API → 项目模块 → `defineOptions` → `defineModel` → `defineProps` + `withDefaults` → `defineEmits` → `defineSlots` → 常量/函数 → `ref`/`reactive` → `computed` → 方法 → 生命周期/`watch` → `defineExpose`
## 自动导入
已自动注册的 API / 组件不要重复 import;仅需类型时用 `import type`。以 `vite.config` 与现有代码为准。
## 模板
- 事件名、插槽名 **kebab-case**;组件 **PascalCase**。
- 受控表单优先 `v-model`;复杂逻辑抽到 `computed` 或方法,不要写在模板里。
- 子组件通过 props/emits 通信,避免随意访问父组件实例。
## defineEmits
使用**基于类型的函数调用签名**,禁止数组 / 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`)
- ❌ 模板中写复杂业务逻辑
@@ -0,0 +1,132 @@
---
name: git-commit
description: 执行 git 提交的标准流程。用户明确要求 commit、提交代码、生成提交信息时使用。涵盖变更检查、Conventional Commits 提交信息、安全约束与提交流程。
---
# Git 提交
> **前置条件**:仅当用户**明确要求**提交时才执行;未要求时不得主动 commit / push。
## 第一步:收集变更状态
同时执行(不要串行等待):
```bash
git status
git diff
```
必要时补充 `git diff --staged`;需了解分支与远程关系时执行 `git status -sb`。
## 第二步:分析变更
- 覆盖**已暂存 + 未暂存 + 未跟踪**中与本次任务相关的文件
- 判断变更类型,映射到 Conventional Commits 的 `type`
- 提交信息聚焦 **why**(1–2 句完整句子),准确反映目的
- **不要**提交 `.env`、密钥、token、credentials 等敏感文件;若用户坚持,须明确警告
- 无相关变更时不执行 commit
## 第三步:提交信息格式(Conventional Commits)
```
<type>(<scope>): <subject>
<body>
```
### type(必选)
| type | 用途 |
|------|------|
| `feat` | 新功能 |
| `fix` | 缺陷修复 |
| `docs` | 文档 |
| `style` | 格式(不影响逻辑,如空格、分号) |
| `refactor` | 重构(非新功能、非修 bug) |
| `perf` | 性能优化 |
| `test` | 测试 |
| `build` | 构建系统或外部依赖 |
| `ci` | CI 配置 |
| `chore` | 其他杂项(不改 src/test) |
| `revert` | 回滚提交 |
### subject(必选)
- 使用**祈使句**,简明描述做了什么
- 不加句号;建议 50 字符以内
- 示例:`新增用户登录表单`、`修复表格分页重置问题`
### scope(可选)
- 括号内标明影响模块,如 `feat(auth)`、`fix(table)`
- 跨模块或难以界定时可省略
### body(可选)
- 说明**为什么**改、改了什么影响
- 与 subject 空一行
### 示例
```
fix(theme): 切换明暗模式时重新生成主题色阶
generate 依赖 isDark 生成色阶,切换后需重新写入 --primary-* 变量,
否则暗色模式下主色显示异常。
```
```
feat(user): 新增用户列表批量删除
```
## 第四步:执行提交(顺序)
```bash
git add <相关文件>
git commit -m "$(cat <<'EOF'
feat(user): 新增用户列表批量删除
补充说明变更原因与影响(可选)
EOF
)"
git status
```
PowerShell 环境可用:
```powershell
git commit -m "feat(user): 新增用户列表批量删除"
git commit -m "fix(theme): 切换明暗模式时重新生成主题色阶" -m "generate 依赖 isDark 生成色阶,切换后需重新写入 CSS 变量。"
```
## 安全约束(必须遵守)
| 禁止 | 说明 |
|------|------|
| `git config` 修改 | 不更改任何 git 配置 |
| `push --force` / `reset --hard` | 除非用户明确要求 |
| `--no-verify` / `--no-gpg-sign` | 除非用户明确要求 |
| 对 main/master force push | 须警告用户 |
| 无用户请求时 push | 不主动推送远程 |
### `--amend` 仅当全部满足
1. 用户明确要求 amend,**或** pre-commit hook 自动改文件需纳入本次提交
2. HEAD 为当前会话创建的 commit,且**未 push**
3. hook 拒绝时:**修复后新建 commit**,不要 amend
## 提交后
- `git status` 确认成功
- hook 失败:修复问题后**重新 commit**(新提交,非 amend)
- 将 commit hash / 摘要反馈给用户
- 用户未要求时**不要 push**
## 检查清单
- [ ] 用户已明确要求提交
- [ ] 已查看 status + diff
- [ ] 提交信息符合 Conventional Commits
- [ ] 未包含敏感文件
- [ ] 提交后 status 正常
@@ -0,0 +1,153 @@
---
name: project-api
description: 在本模板项目中新增或修改 API 接口、类型定义与 vite-plugin-mock 模拟数据。涉及 getBaseApi、http、baseAPI、mock 目录、分页响应格式时使用。
---
# API 与 Mock
## 接口层结构
每个业务模块一个文件,例如 `src/apis/system/user.ts`:
```ts
import { getBaseApi } from '@/apis/base'
import http from '@/utils/http'
export interface ListItem {
id: string
name: string
status: Status
createTime: string
// ...
}
/** 标准 CRUD */
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/user' })
/** 非标准接口单独声明 */
export function getDictDataList(params: { code: string } & Pagination) {
return http.get<PageRes<DictDataItem[]>>('/system/dict/getDictDataList', params)
}
```
## getBaseApi 约定路径
| 方法 | HTTP | 路径 |
|------|------|------|
| getList | GET | `{baseUrl}/getList` |
| getDetail | GET | `{baseUrl}/getDetail` |
| add | POST | `{baseUrl}/add` |
| update | POST | `{baseUrl}/update` |
| delete | POST | `{baseUrl}/delete` |
列表参数:`{ page, size, ...filters }`
删除参数:`{ ids: string[] }`
## 响应格式
```ts
// 单条/布尔
interface ApiRes<T> {
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()` 的场景。
@@ -0,0 +1,214 @@
---
name: project-crud
description: 在本模板项目中创建或修改 CRUD 列表页、表格列、搜索区、表单弹窗。使用 GiPageLayout、useTable、GiButton、a-table、GiForm、useDict 时加载本技能。
---
# CRUD 列表页开发
> 写/改 `.vue` 前先读 [code-vue](../code-vue/SKILL.md);写样式前读 [code-css](../code-css/SKILL.md)。
## 标准页面结构
```vue
<template>
<GiPageLayout margin collapsed>
<template #left><!-- 可选:树/分类 --></template>
<a-row justify="space-between" class="g-row-tool">
<a-space wrap>
<GiButton type="add" @click="onAdd" />
<GiButton type="delete" @click="onBatchDelete" />
</a-space>
<a-space wrap>
<!-- 搜索表单项 -->
<GiButton type="search" @click="search" />
<GiButton type="reset" @click="reset" />
</a-space>
</a-row>
<a-table
class="g-table"
row-key="id"
:loading="loading"
:data="tableData"
:columns="tableColumns"
:bordered="{ cell: true }"
:scroll="{ x: '100%', y: '100%', minWidth: 1200 }"
:pagination="pagination"
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
:selected-keys="selectedKeys"
@select="select"
@select-all="selectAll"
/>
<XxxFormModal ref="XxxFormModalRef" @save-success="search" />
</GiPageLayout>
</template>
<script setup lang="tsx">
defineOptions({ name: 'ModuleName' })
</script>
```
## useTable 用法
```ts
import type * as T from '@/apis/system/user'
import { baseAPI } from '@/apis/system/user'
import { useDict, useTable } from '@/hooks'
const { dictData } = useDict(['STATUS'] as const)
const queryParams = reactive({ status: '', username: '' })
const {
loading,
tableData,
pagination,
selectedKeys,
search,
select,
selectAll,
fixed, // 操作列 fixed: 'right'(小屏自动取消)
onDelete,
onBatchDelete,
refresh
} = useTable({
listAPI: (page) => baseAPI.getList({ ...page, ...queryParams }),
deleteAPI: (ids) => baseAPI.delete({ ids }),
immediate: true,
rowKey: 'id', // 默认 id
crossPageSelect: false // 跨页多选时设为 true
})
```
要点:
- `search()` 会重置到第 1 页并清空选中
- `refresh()` 保持当前页刷新
- `listAPI` 参数为 `{ page, size }`,需展开到接口
- 空字符串查询条件建议转 `undefined` 再传 API
## GiButton 预设类型
`add` | `edit` | `delete` | `search` | `reset` | `upload` | `import` | `export`
列表工具栏与操作列统一使用 `GiButton`,操作列常用 `size="mini"`。
## 表格列(TSX)
使用 `lang="tsx"`,从 `@arco-design/web-vue` 引入 `TableColumnData`:
```tsx
const tableColumns: TableColumnData[] = [
{
title: '序号',
width: 68,
align: 'center',
render: ({ rowIndex }) => <span>{rowIndex + 1}</span>
},
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true },
{
title: '状态',
width: 100,
align: 'center',
render: ({ record }) => (
<GiCellStatus status={record.status} />
)
},
{
title: '操作',
width: 160,
align: 'center',
fixed: 'right', // 或绑定 useTable 的 fixed
render: ({ record }) => (
<Space>
<GiButton type="edit" size="mini" onClick={() => onEdit(record as T.ListItem)} />
<Popconfirm content="确定删除?" onBeforeOk={() => onDelete(record as T.ListItem)}>
<GiButton type="delete" size="mini" />
</Popconfirm>
</Space>
)
}
]
```
常用单元格组件:`GiCellAvatar`、`GiCellGender`、`GiCellStatus`(从 `@/components/GiCell` 引入或在 TSX 中直接使用)。
## GiPageLayout
| 属性/插槽 | 说明 |
|-----------|------|
| `#left` | 左侧栏(部门树、字典类型列表等) |
| `#header` | 顶栏(少用) |
| `margin` | 外边距模式 |
| `collapsed` | 显示折叠按钮 |
| `size` | 左侧宽度,如 `"280px"` |
左栏选中后通过事件同步右侧列表,参考 `dict/index.vue` 的 `onDictTypeRefresh`。
## 表单弹窗(Modal + GiForm)
```vue
<a-modal v-model:visible="visible" :title="title" :mask-closable="false"
@before-ok="save" @close="close">
<GiForm ref="GiFormRef" :model-value="form" :columns="formColumns"
@update:model-value="Object.assign(form, $event)" />
</a-modal>
```
```ts
import type { FormColumnItem } from '@/components/index'
import { useResetReactive } from '@/hooks'
const [form, resetForm] = useResetReactive({ id: '', name: '', status: '1' as Status })
const GiFormRef = useTemplateRef<InstanceType<typeof GiForm>>('GiFormRef')
const add = () => {
detailId.value = ''
visible.value = true
}
const edit = async (id: string) => {
visible.value = true
detailId.value = id
Object.assign(form, (await baseAPI.getDetail({ id })).data)
}
const close = () => {
GiFormRef.value?.formRef?.resetFields()
resetForm()
}
const save = async () => {
const valid = await GiFormRef.value?.formRef?.validate()
if (valid) return false
// await baseAPI.add(form) 或 baseAPI.update(form)
emit('save-success')
return true
}
defineExpose({ add, edit })
```
`formColumns` 常用 `type`:`input`、`select`、`tree-select`、`radio-group`、`switch`、`textarea`。校验可用 `required`、`rules`;正则见 `@/utils/regexp`。
## 字典下拉
```ts
const { dictData } = useDict(['STATUS'] as const)
// 模板:<a-select :options="dictData.STATUS" />
```
## 表格增强方案选型
| 需求 | 方案 |
|------|------|
| 内置斑马纹/全屏/列设置 | `GiTable` |
| 继续用 `a-table` + 工具栏/列设置 | `GiTableSetting` 包裹(见 `views/table/custom3`) |
| 简单列表 | `a-table` + `useTable`(系统模块主流) |
## 检查清单
- [ ] `defineOptions({ name })` 已设置
- [ ] `row-key="id"` 与 `useTable` 的 `rowKey` 一致
- [ ] 删除走 `onDelete` / `onBatchDelete` 或自定义 `Popconfirm` + API
- [ ] 弹窗 `defineExpose({ add, edit })`,父组件 `useTemplateRef` 调用
- [ ] 保存成功后 `emit('save-success')` 并触发 `search`
@@ -0,0 +1,171 @@
---
name: project-guide
description: 后台管理模板的项目总览与前端强制规范。涵盖 Vue 3 + Arco Design + Gi 组件库、目录结构、自动导入、CRUD/API 约定与代码风格。在当前仓库内开发、新增页面、查阅架构或不确定项目规范时使用。
---
# 项目开发指南
> **适用范围**:本仓库 `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">` |
| 构建 | Vite 7 |
| UI | Arco Design Vue(按需自动导入)+ 本地 `Gi*` 组件 |
| 状态 | Pinia + pinia-plugin-persistedstate |
| 路由 | Vue Router 4(Hash 模式) |
| HTTP | `@/utils/http`,统一响应类型 `ApiRes<T>`、`PageRes<T>` |
| 工具 | @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/
├── apis/ # 接口(按业务模块分文件,标准 CRUD 用 getBaseApi)
├── components/ # Gi* 业务组件 + 部分通用组件
├── hooks/ # 组合式函数(useTable、useDict 等)
├── hooks/app/ # 业务域 hooks(useDept、useRole)
├── stores/ # Pinia
├── views/ # 页面(按模块分子目录)
├── router/ # 路由与守卫
├── utils/ # http、auth、regexp、deepClone 等
├── types/ # 全局类型(global.d.ts、api.d.ts)
mock/ # vite-plugin-mock 模拟数据
```
页面文件命名:
```
src/views/{module}/index.vue # 列表页
src/views/{module}/XxxFormModal.vue # 新增/编辑弹窗(有表单时必建)
src/apis/{module}/{name}.ts # 类型定义 + CRUD API(或 apis/system/user.ts)
mock/{module}/{name}.ts # 与 API 路径一致的 Mock
```
## 全局类型(`src/types/`)
- `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) 约定
+10
View File
@@ -0,0 +1,10 @@
# 环境变量 (命名必须以 VITE_ 开头)
# 接口前缀:真实后端(本项目的《03-接口设计》约定 /api/v1/...)
VITE_API_PREFIX = '/api/v1'
# 代理目标:本地 Spring Boot
VITE_API_BASE_URL = 'http://localhost:8080'
# 开发环境地址前缀
VITE_BASE = '/'
+6
View File
@@ -0,0 +1,6 @@
# 环境变量 (命名必须以 VITE_ 开头)
# 生产走同源反向代理
VITE_API_PREFIX = '/api/v1'
VITE_API_BASE_URL = 'https://api.tianbaotong.cn'
VITE_BASE = '/'
+5
View File
@@ -0,0 +1,5 @@
# 环境变量 (命名必须以 VITE_ 开头)
VITE_API_PREFIX = '/api/v1'
VITE_API_BASE_URL = 'https://api-test.tianbaotong.cn'
VITE_BASE = '/'
+82
View File
@@ -0,0 +1,82 @@
name: Deploy to GitHub Pages
# 触发条件:推送或PR到master分支,以及手动触发
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
# 权限配置
permissions:
contents: read
pages: write
id-token: write
# 环境配置
concurrency:
group: pages
cancel-in-progress: true
jobs:
# 构建作业
build:
runs-on: ubuntu-latest
steps:
# 检出代码
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # 确保获取完整的历史记录
# 设置Node.js环境
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.19.4
cache: npm # 缓存npm依赖
# 安装依赖
- name: Install dependencies
run: npm ci --legacy-peer-deps # 使用legacy-peer-deps解决peer dependency冲突
# 构建项目
- name: Build
run: npm run build
# 检查构建结果
- name: Check build output
run: |
echo "=== 构建文件列表 ==="
ls -la ./dist
echo ""
echo "=== Gzip 压缩文件统计 ==="
find ./dist -name "*.gz" | wc -l | xargs echo "压缩文件数量:"
find ./dist -name "*.gz" -exec ls -lh {} \; | head -10
echo ""
echo "=== 构建完成 ==="
# 上传构建产物作为artifact
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./dist
# 部署作业
deploy:
# 只有在构建成功且是push或手动触发时才运行
needs: build
if: github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
# 部署到GitHub Pages
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
with:
# 使用官方的deploy-pages action,它会自动处理所有配置
artifact_name: github-pages
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
# .vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.stylelintcache
+31
View File
@@ -0,0 +1,31 @@
# 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/
+51
View File
@@ -0,0 +1,51 @@
# AGENTS.md
面向 AI 编码助手的项目指南。规范以 `.agents/` 为唯一来源。
## 常驻规则(Agent 行为准则)
> 源文件:`.agents/rules/agent.md`(修改后请同步本节)
- 始终使用**简体中文**与用户沟通。
- 未经用户明确要求,**不要**创建说明文档、执行 git commit/push、修改 git 配置。
- 改动范围最小化;优先复用项目已有抽象,禁止凭空引入新架构。
- 改代码前先阅读项目既有实现;任务完成后视情况运行 lint / typecheck。
- **按需读取** skills,不要预加载全部。
**编码前按任务加载 skill:**
| 任务 | 技能 |
|------|------|
| 写/改 `.vue` | `.agents/skills/code-vue/SKILL.md` |
| 写/改样式 | `.agents/skills/code-css/SKILL.md` |
| 写/改 `.ts` / composable | `.agents/skills/code-ts/SKILL.md` |
| 项目约定、Gi 组件 | `.agents/skills/project-guide/SKILL.md` |
| CRUD 列表/表单 | `.agents/skills/project-crud/SKILL.md` |
| API / Mock | `.agents/skills/project-api/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
pnpm dev # 开发(Mock,/mock 前缀)
pnpm build # 生产构建
pnpm typecheck # vue-tsc
pnpm lint # ESLint
pnpm lint:style # Stylelint
```
- 路径别名:`@` → `src/`,`~` → 项目根目录
- 开发默认 Mock;联调真实后端见 `vite.config.ts` 与 `.env`
- 登录演示账号见 `README.md`(如 admin / 123456)
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+622
View File
@@ -0,0 +1,622 @@
<div align="center">
<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)
## 简介
**Gi Admin Pro** 是一个基于 Vue3、Vite、TypeScript、Arco Design Vue、Pinia、VueUse 等的免费中后台模版,它使用了最新的前端技术栈,内置丰富的主题配置,有着极高的代码规范,基于 mock 实现的动态数据展示,开箱即用的模板,也可用于学习参考。
> **Gi 前缀含义:** G:代表全局 i:代表我的
>
> Gi 用来定义全局组件前缀,如 GiTable、GiForm、GiPageLayout等
## 特性
- **最新技术栈**:使用 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
npm install
```
- 运行
```bash
npm run dev
```
- 打包
```bash
npm run build
```
## 开源作者
**Lin**
## 常见问题
**为什么安装依赖不成功?**
检查`node`版本,最好使用原生镜像`npm`
还原镜像
```
npm config set registry https://registry.npmjs.org/
```
**为什么选择 Arco 组件库,而不是 Element Plus?**
[Element Plus 对比 Arco design](https://juejin.cn/post/7294219581894705190)
**为什么全局组件使用前缀 Gi?**
全局组件设置了按需引入,使用前缀,方便和局部组件做区分
**为什么组件使用单词大写开头 (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" />
+56
View File
@@ -0,0 +1,56 @@
import antfu from '@antfu/eslint-config'
// https://github.com/antfu/eslint-config
export default antfu(
{
vue: true,
typescript: true,
ignores: [
'README.md',
'src/types/shims-vue.d.ts'
]
},
{
// Remember to specify the file glob here, otherwise it might cause the vue plugin to handle non-vue files
files: ['**/*.vue'],
rules: {
'vue/block-order': [2, {
order: [['script', 'template'], 'style']
}], // 强制组件顶级元素的顺序
'vue/html-self-closing': [0, {
html: {
void: 'never',
normal: 'always',
component: 'never'
}
}], // 强制自结束样式
'vue/custom-event-name-casing': [2, 'kebab-case'], // 对自定义事件名称强制使用特定大小写
'vue/singleline-html-element-content-newline': 0, // 要求在单行元素的内容前后换行
'vue/first-attribute-linebreak': 0, // 强制第一个属性的位置
'vue/define-macros-order': [2, {
order: ['defineOptions', 'defineModel', 'defineProps', 'defineEmits', 'defineSlots'],
defineExposeLast: true
}], // 强制执行定义限制和定义弹出编译器宏的顺序
'vue/html-indent': 0, // 在《模板》中强制一致的缩进
'vue/html-closing-bracket-newline': 0 // 要求或不允许在标记的右括号前换行
}
},
{
// Without `files`, they are general rules for all files
rules: {
'curly': [0, 'all'], // 对所有控制语句强制使用一致的大括号样式
'dot-notation': 0, // 尽可能强制使用点表示法。 在 JavaScript 中,可以使用点表示法 (foo.bar) 或方括号表示法 (foo["bar"]) 访问属性
'no-new': 0, // 不允许在赋值或比较之外使用 new 运算符
// 'no-console': 2, // 禁止使用 console
'no-process-env': 0,
'style/arrow-parens': [2, 'always'], // 箭头函数参数需要括号
'style/brace-style': [2, '1tbs', { allowSingleLine: true }], // 对块执行一致的大括号样式
'style/comma-dangle': [2, 'never'], // 要求或不允许尾随逗号
'ts/consistent-type-definitions': 0,
'ts/no-unused-expressions': 0,
'node/prefer-global/process': 0,
'antfu/top-level-function': 0,
'antfu/if-newline': 0
}
}
)
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<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>
<div id="app">
<!-- 首屏骨架:在 JS 包下载并执行之前显示,避免白屏 -->
<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>
</section>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
{
"name": "xt-frontend-pc",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"build-tsc": "vue-tsc --noEmit && vite build",
"preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:style": "stylelint \"**/*.{vue,scss,css}\" --cache",
"lint:style:fix": "stylelint \"**/*.{vue,scss,css}\" --cache --fix"
},
"dependencies": {
"@arco-design/color": "^0.4.0",
"@iconify/vue": "^5.0.0",
"@vueuse/core": "^13.6.0",
"animate.css": "^4.1.1",
"axios": "^0.27.2",
"dayjs": "^1.11.4",
"echarts": "^6.0.0",
"jsencrypt": "^3.5.4",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"sortablejs": "^1.15.6",
"vue": "^3.5.18",
"vue-color-kit": "^1.0.6",
"vue-draggable-plus": "^0.6.0",
"vue-router": "^4.5.1",
"xe-utils": "^3.7.8"
},
"devDependencies": {
"@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",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.5.0",
"less": "^4.4.0",
"postcss": "^8.5.6",
"postcss-html": "^1.8.0",
"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",
"unplugin-auto-import": "^20.0.0",
"unplugin-icons": "^22.5.0",
"unplugin-vue-components": "^29.0.0",
"vite": "^7.0.6",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^8.0.5",
"vue-tsc": "^3.0.5"
},
"description": "数据信息填报系统 · PC 管理端(管理员 / 班主任)"
}
+6565
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!--
浏览器页签图标。
与 src/assets/images/logo.svg 同一识别符号,但**刻意做减法**:
favicon 实际渲染尺寸只有 16~32px,渐变、高光、内描边到这里全都会糊成噪点,
所以只保留「圆角方形 + 填」两个元素 —— 在 1 倍屏下反而是最清楚的。
配色仍取腾讯云主色 #0052D9。
-->
<rect x="0" y="0" width="64" height="64" rx="14" fill="#0052D9" />
<text
x="32" y="33.5"
fill="#FFFFFF"
font-size="40"
font-weight="700"
font-family="Songti SC, STSong, SimSun, Noto Serif SC, serif"
text-anchor="middle"
dominant-baseline="central"
>填</text>
</svg>

After

Width:  |  Height:  |  Size: 763 B

+55
View File
@@ -0,0 +1,55 @@
/**
* 首屏骨架样式。
*
* 独立成文件(不经构建),因为它要在 JS 包下载、执行之前就生效 ——
* 放进 src/ 走打包的话,这段样式反而会晚于首屏出现。
*
* 颜色用腾讯云主色 #0052D9(与 src/styles/arco-ui/xt-theme.less 一致);
* 这里不能写 var(--primary-6),因为此时 Arco 的样式表还没加载。
*/
body {
overflow: hidden;
}
.init-box {
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
background: #f3f3f3;
}
.init-loading {
display: flex;
justify-content: space-between;
width: 40px;
height: 40px;
}
.init-loading-rect {
width: 15%;
height: 100%;
background-color: #0052d9;
border-radius: 2px;
animation: init-wave 1.2s infinite ease-in-out;
}
.init-loading-rect:nth-child(1) { animation-delay: -1.2s; }
.init-loading-rect:nth-child(2) { animation-delay: -1.1s; }
.init-loading-rect:nth-child(3) { animation-delay: -1s; }
.init-loading-rect:nth-child(4) { animation-delay: -0.9s; }
.init-loading-rect:nth-child(5) { animation-delay: -0.8s; }
@keyframes init-wave {
0%,
40%,
100% {
transform: scaleY(0.4);
}
20% {
transform: scaleY(1);
}
}
+32
View File
@@ -0,0 +1,32 @@
<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">
import { useTheme } from '@/hooks'
import { useDictStore } from '@/stores'
defineOptions({ name: 'App' })
const { initThemeColor } = useTheme()
initThemeColor()
// 字典数据:本项目的字典接口属 M1 范围(后端尚未实现),
// 现在启动就请求会稳定 404 并在控制台刷错误。
// 页面用的枚举(组织类型 / 租户状态 / 角色名)目前以常量形式定义在
// @/apis/xt/* 与 @/config/xt-routes 里,不依赖字典。
// 待后端 `/system/dict/*` 落地后,把下面两行恢复即可。
// const dictStore = useDictStore()
// dictStore.getDictData()
</script>
<style lang="scss" scoped>
.loading-icon {
width: 30px;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,5 @@
export interface AreaItem {
label: string
code: string
children?: AreaItem[]
}
+39
View File
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,8 @@
export interface CateTreeItem {
id: string
name: string
pid: string | null
type: string | null
disabled: boolean
children: CateTreeItem[]
}
+9
View File
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,13 @@
// 文件
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
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,16 @@
// 表格项目
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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,11 @@
import http from '@/utils/http'
/** 请求成功的接口 */
export function getTestSuccess() {
return http.get<string>('/test/success')
}
/** 请求失败的接口 */
export function getTestFail() {
return http.get<any>('/test/fail')
}
+24
View File
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,34 @@
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
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 本项目业务接口的公共出口与类型。
*
* 与模板的 `src/apis/*` 分开,是因为两边契约不同:
* - 模板:`/user/login`、`{ success, message }`、`{ records, total }`
* - 本项目:`/auth/login`(《03-接口设计》§5)、`{ code, msg, data }`、`{ list, total }`
* 形状适配统一在 `@/utils/http` 里做,这里只负责路径与类型。
*/
import http from '@/utils/http'
export { http }
/** 登录接口(§5.1) */
export interface LoginReq {
username: string
/** RSA 公钥加密后的 Base64,不是明文 */
password: string
/** 账号在多个租户下重名时必填 */
tenantCode?: string
captchaKey?: string
captchaCode?: string
}
export interface XtUserInfo {
id: number
username: string
realName: string
userType: number
avatarUrl: string | null
orgId: number | null
}
export interface XtTenantInfo {
id: number
tenantCode: string
tenantName: string
logoUrl: string | null
}
/** 登录 / 当前用户响应(§5.1 / §5.2) */
export interface LoginVO {
token: string | null
expireSeconds: number
user: XtUserInfo
tenant: XtTenantInfo | null
roleCodes: string[]
/** 菜单键(决定左侧导航) */
menus: string[]
/** 细粒度权限码(决定按钮显隐) */
permissions: string[]
}
/** 图形验证码 */
export interface CaptchaVO {
captchaKey: string
/** data:image/png;base64,... */
image: string
expireSeconds: number
}
// ── 认证(§5.1 / §5.2 / §5.9)──────────────────────────
/** 登录(密码需先经 {@link encryptPassword} 加密) */
export function login(data: LoginReq) {
return http.post<LoginVO>('/auth/login', data)
}
/** 当前用户 + 菜单 + 权限码 */
export function getMe() {
return http.get<LoginVO>('/auth/me')
}
/** 登出(服务端会把 token 加入黑名单) */
export function logout() {
return http.post<void>('/auth/logout')
}
/** 图形验证码(连续失败 2 次后服务端会要求) */
export function getCaptcha() {
return http.get<CaptchaVO>('/auth/captcha')
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 组织架构接口(《03-接口设计》§6.1~§6.3)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
/** 组织树节点(§6.1) */
export interface OrgTreeVO {
id: number
orgName: string
/** 1 学校 / 2 院系或专业 / 3 班级 / 4 报名点 */
orgType: number
orgCode: string | null
leaderUserId: number | null
leaderName: string | null
sortNo: number
status: number
studentCount: number | null
children: OrgTreeVO[]
}
/** 新增 / 修改请求(§6.2) */
export interface OrgUpsertReq {
parentId?: number | null
orgName: string
orgType: number
orgCode: string
leaderUserId?: number | null
sortNo?: number
}
/**
* 组织树。
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
*/
export function getOrgTree(params: { withCount?: boolean, rootId?: number } = {}) {
return http.get<OrgTreeVO[]>('/orgs/tree', params)
}
/** 新增,返回新节点 id */
export function addOrg(data: OrgUpsertReq) {
return http.post<number>('/orgs', data)
}
/** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */
export function updateOrg(id: number, data: OrgUpsertReq) {
return http.put<void>(`/orgs/${id}`, data)
}
/** 删除;有下级组织或账号时返回 40015 并给出占用清单 */
export function deleteOrg(id: number) {
return http.delete<void>(`/orgs/${id}`)
}
/** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */
export const ORG_TYPE_NAMES: Record<number, string> = {
1: '学校',
2: '院系 / 专业',
3: '班级',
4: '报名点'
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 租户管理接口(《03-接口设计》§6.5~§6.6)。
*
* ⚠️ 全部接口**仅平台运营可用**。学校管理员调用会得到 40301,
* 前端据此隐藏入口(见 `@/config/xt-routes` 的菜单过滤)。
*/
import http from '@/utils/http'
/** 租户(§6.5) */
export interface TenantVO {
id: number
tenantCode: string
tenantName: string
tenantType: number
contactName: string | null
contactPhone: string | null
packageId: number | null
expireTime: string | null
status: number
logoUrl: string | null
createTime: string | null
}
/** 开通租户请求(§6.6) */
export interface TenantCreateReq {
tenantName: string
tenantCode: string
tenantType: number
contactName: string
contactPhone: string
packageId?: number
expireTime: string
adminUsername: string
adminRealName: string
adminPhone: string
}
/** 开通结果:初始密码**只返回这一次** */
export interface TenantCreateResult {
id: number
adminInitialPassword: string
miniProgramScene: string
}
/** 分页查询参数(后端用 pageNum/pageSize,模板的 useTable 用 page/size) */
export interface TenantQuery {
keyword?: string
status?: number
expireBefore?: string
pageNum?: number
pageSize?: number
}
export function getTenantList(params: TenantQuery = {}) {
return http.get<{ list: TenantVO[], total: number }>('/tenants', params)
}
export function getTenantDetail(id: number) {
return http.get<TenantVO>(`/tenants/${id}`)
}
/**
* 开通租户:一次请求完成「建租户 + 发放 6 个内置角色 + 创建首个管理员」。
* 返回的 `adminInitialPassword` 只在这一次响应里,界面必须让用户当场记下。
*/
export function createTenant(data: TenantCreateReq) {
return http.post<TenantCreateResult>('/tenants', data)
}
/** 租户类型 → 中文名 */
export const TENANT_TYPE_NAMES: Record<number, string> = {
1: '学校',
2: '认定机构',
3: '报名点',
4: '企业'
}
/** 租户状态 → 文案与颜色 */
export const TENANT_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '已停用', color: 'gray' },
1: { text: '正常', color: 'green' },
2: { text: '试用', color: 'orange' }
}
@@ -0,0 +1,77 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 400" width="600" height="400"
role="img" aria-label="散乱的材料自动归集为规范包">
<!--
登录页头图 · 材料归集
================================================================
配色全部取自腾讯云 / TDesign 官方色板(与 xt-theme.less 一致):
主色 #0052D9 主色 hover #266FE8 主色浅阶 #699EF5
浅阶 #96BBF8 更浅 #BBD3FB 最浅 #D4E3FC / #ECF2FE
成功 #00A870 中性描边 #E7E7E7 中性底 #F3F3F3
画面在说什么:
左边两张「散的」材料卡(一寸照片、身份证),顺着虚线流进右边
一叠「已经码齐」的规范文件,右上角一枚绿色通过章。
对应产品的三件事 —— 收集 → 按规则整理 → 过审。
刻意不画文字:文字在不同字号下会糊,插画只负责传达"秩序感"。
-->
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<!-- ── 背景装饰 ────────────────────────────────── -->
<circle cx="492" cy="86" r="100" fill="#ECF2FE" />
<circle cx="88" cy="330" r="66" fill="#F3F3F3" />
<circle cx="212" cy="94" r="5" fill="#BBD3FB" />
<circle cx="252" cy="74" r="3.5" fill="#D4E3FC" />
<circle cx="146" cy="322" r="4.5" fill="#DCDCDC" />
<circle cx="194" cy="342" r="3" fill="#E7E7E7" />
<!-- ── 规范文件堆叠(右)───────────────────────── -->
<!-- 底层 -->
<rect x="300" y="188" width="168" height="110" rx="14" fill="#D4E3FC" />
<!-- 中层 -->
<rect x="316" y="172" width="168" height="110" rx="14" fill="#96BBF8" />
<!-- 顶层(当前这一份) -->
<rect x="332" y="156" width="168" height="110" rx="14" fill="#FFFFFF"
stroke="#BBD3FB" stroke-width="2" />
<!-- 顶层内容:一个标题条 + 三行正文,示意"已按模板排好" -->
<rect x="352" y="178" width="54" height="8" rx="4" fill="#0052D9" />
<rect x="352" y="198" width="126" height="6" rx="3" fill="#E7E7E7" />
<rect x="352" y="212" width="126" height="6" rx="3" fill="#E7E7E7" />
<rect x="352" y="226" width="84" height="6" rx="3" fill="#E7E7E7" />
<!-- ── 待归集的材料(左)───────────────────────── -->
<!-- 一寸照片:圆形占位代表人脸区 -->
<g transform="rotate(-8 138 154)">
<rect x="104" y="112" width="68" height="84" rx="10" fill="#FFFFFF"
stroke="#DCDCDC" stroke-width="2" />
<circle cx="138" cy="140" r="14" fill="#D4E3FC" />
<rect x="120" y="166" width="36" height="5" rx="2.5" fill="#E7E7E7" />
</g>
<!-- 身份证:方形占位代表证件照区 -->
<g transform="rotate(7 106 253)">
<rect x="58" y="222" width="96" height="62" rx="10" fill="#FFFFFF"
stroke="#DCDCDC" stroke-width="2" />
<rect x="72" y="236" width="30" height="34" rx="6" fill="#BBD3FB" />
<rect x="112" y="240" width="32" height="5" rx="2.5" fill="#E7E7E7" />
<rect x="112" y="252" width="32" height="5" rx="2.5" fill="#E7E7E7" />
<rect x="112" y="264" width="20" height="5" rx="2.5" fill="#E7E7E7" />
</g>
<!-- ── 归集路径:虚线把"散"和"齐"连起来 ─────────── -->
<path d="M180 158 C232 156, 274 168, 316 186"
stroke="#699EF5" stroke-width="2" stroke-linecap="round"
stroke-dasharray="7 7" opacity="0.6" />
<path d="M162 268 C222 270, 272 250, 316 226"
stroke="#699EF5" stroke-width="2" stroke-linecap="round"
stroke-dasharray="7 7" opacity="0.6" />
<!-- ── 通过章:盖在规范文件的右上角 ──────────────── -->
<!-- 底下垫一圈主色光晕,避免绿章与蓝卡生硬相接 -->
<circle cx="506" cy="134" r="34" fill="#0052D9" opacity="0.06" />
<circle cx="506" cy="134" r="27" fill="#00A870" />
<circle cx="506" cy="134" r="27" stroke="#FFFFFF" stroke-width="3" opacity="0.22" />
<path d="M494 134 l9 9 l18 -18" stroke="#FFFFFF" stroke-width="4"
stroke-linecap="round" stroke-linejoin="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+45
View File
@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="填报通">
<!--
填报通 品牌标
================================================================
配色取自腾讯云 / TDesign 官方色板(与 src/styles/arco-ui/xt-theme.less 一致):
主色 #0052D9 主色亮阶 #2975E1 浅底 #ECF2FE
设计说明:
· 圆角方形 + 「填」字,与产品原型(docs/prototype 的 .mark)保持同一识别符号
· 用渐变而不是纯色,是为了在 32px 的侧栏尺寸下也不显得扁平死板
· 顶部一道极淡的高光,给方形一点体积感(模仿纸质卡片的受光)
· 不画对勾等附加符号:尺寸一旦缩到 favicon,多余元素只会变成噪点
-->
<defs>
<linearGradient id="xtLogoBg" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0%" stop-color="#2975E1" />
<stop offset="100%" stop-color="#0052D9" />
</linearGradient>
<linearGradient id="xtLogoGloss" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.22" />
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0" />
</linearGradient>
</defs>
<!-- 底板 -->
<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)" />
<!-- 内描边:让标在浅色底上也有轮廓,不至于"浮"着 -->
<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" />
<!-- 「填」字 -->
<text
x="32" y="33.5"
fill="#FFFFFF"
font-size="37"
font-weight="600"
font-family="Songti SC, STSong, SimSun, Noto Serif SC, Source Han Serif SC, serif"
text-anchor="middle"
dominant-baseline="central"
>填</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+78
View File
@@ -0,0 +1,78 @@
/* 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')
}
+160
View File
@@ -0,0 +1,160 @@
/* 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']
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']
AInput: typeof import('@arco-design/web-vue')['Input']
AInputNumber: typeof import('@arco-design/web-vue')['InputNumber']
AInputPassword: typeof import('@arco-design/web-vue')['InputPassword']
AInputSearch: typeof import('@arco-design/web-vue')['InputSearch']
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']
APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
APopover: typeof import('@arco-design/web-vue')['Popover']
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']
ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
ATree: typeof import('@arco-design/web-vue')['Tree']
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']
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 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 AInput: typeof import('@arco-design/web-vue')['Input']
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 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 APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
const APopover: typeof import('@arco-design/web-vue')['Popover']
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 ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
const ATree: typeof import('@arco-design/web-vue')['Tree']
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 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']
}
@@ -0,0 +1,86 @@
<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>
@@ -0,0 +1,5 @@
import Button from './GiButton.vue'
export type ButtonInstance = InstanceType<typeof Button>
export * from './type'
export { Button as GiButton }
@@ -0,0 +1,5 @@
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' | ''
}
@@ -0,0 +1,31 @@
<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>
@@ -0,0 +1,28 @@
<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>
@@ -0,0 +1,24 @@
<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>
@@ -0,0 +1,33 @@
<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>
@@ -0,0 +1,6 @@
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 }
@@ -0,0 +1,33 @@
/**
* @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
@@ -0,0 +1,58 @@
.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);
}
}
@@ -0,0 +1,5 @@
import Dot from './GiDot.tsx'
export type * from './type'
export type DotInstance = InstanceType<typeof Dot>
export { Dot as GiDot }
+10
View File
@@ -0,0 +1,10 @@
/** 点的类型 */
export type DotType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
/** 组件属性定义 */
export interface DotProps {
/** 点的类型 */
type?: DotType
/** 是否启用动画效果 */
animation?: boolean
}
@@ -0,0 +1,176 @@
<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>
@@ -0,0 +1,5 @@
import EditTable from './GiEditTable.vue'
export type * from './type'
export type EditTableInstance = InstanceType<typeof EditTable>
export { EditTable as GiEditTable }
@@ -0,0 +1,81 @@
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>
}
@@ -0,0 +1,65 @@
<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>
@@ -0,0 +1,5 @@
import Flex from './GiFlex.vue'
export type FlexInstance = InstanceType<typeof Flex>
export * from './type'
export { Flex as GiFlex }
+29
View File
@@ -0,0 +1,29 @@
/** 组件属性定义 */
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
}
@@ -0,0 +1,273 @@
<!--
@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>
@@ -0,0 +1,5 @@
import Form from './GiForm.vue'
export type * from './type'
export type FormInstance = InstanceType<typeof Form>
export { Form as GiForm }
+299
View File
@@ -0,0 +1,299 @@
/**
* @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 }>
}
@@ -0,0 +1,99 @@
<!--
@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>
@@ -0,0 +1,5 @@
import IconBox from './GiIconBox.vue'
export * from './type'
export type IconBoxInstance = InstanceType<typeof IconBox>
export { IconBox as GiIconBox }
@@ -0,0 +1,6 @@
/** 组件属性定义 */
export interface IconBoxProps {
color?: 'primary' | 'success' | 'warning' | 'danger' | string
size?: number
shape?: 'square' | 'round'
}
@@ -0,0 +1,50 @@
<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>
@@ -0,0 +1,5 @@
import InputSearch from './GiInputSearch.vue'
export type InputSearchInstance = InstanceType<typeof InputSearch>
export * from './type'
export { InputSearch as GiInputSearch }
@@ -0,0 +1,5 @@
export interface InputSearchProps {
placeholder?: string
disabled?: boolean
readonly?: boolean
}
@@ -0,0 +1,16 @@
<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>
@@ -0,0 +1,79 @@
<!--
@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>
@@ -0,0 +1,7 @@
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 }
@@ -0,0 +1,11 @@
/** 组件属性定义 */
export interface OptionItemProps {
/** 图标名称 */
icon?: string
/** 选项文本 */
label?: string
/** 是否显示更多图标 */
more?: boolean
/** 是否激活状态 */
active?: boolean
}
@@ -0,0 +1,192 @@
<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>
@@ -0,0 +1,100 @@
<!--
@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>
@@ -0,0 +1,5 @@
import PageLayout from './GiPageLayout.vue'
export type PageLayoutInstance = InstanceType<typeof PageLayout>
export * from './type'
export { PageLayout as GiPageLayout }
@@ -0,0 +1,14 @@
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
}
@@ -0,0 +1,440 @@
<!--
@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>
@@ -0,0 +1,5 @@
import Table from './GiTable.vue'
export type * from './type'
export type TableInstance = InstanceType<typeof Table>
export { Table as GiTable }
@@ -0,0 +1,53 @@
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
}
@@ -0,0 +1,291 @@
<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>
@@ -0,0 +1,5 @@
import TableSetting from './GiTableSetting.vue'
export type * from './type'
export type TableSettingInstance = InstanceType<typeof TableSetting>
export { TableSetting as GiTableSetting }
@@ -0,0 +1,22 @@
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
}
@@ -0,0 +1,141 @@
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
@@ -0,0 +1,135 @@
/**
* @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>
)
}
})
@@ -0,0 +1,5 @@
import Tag from './GiTag.tsx'
export type TagInstance = InstanceType<typeof Tag>
export * from './type'
export { Tag as GiTag }
+166
View File
@@ -0,0 +1,166 @@
$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
@@ -0,0 +1,13 @@
/** 组件属性定义 */
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
}
@@ -0,0 +1,16 @@
<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>
@@ -0,0 +1,4 @@
import ThemeBtn from './GiThemeBtn.vue'
export type ThemeBtnInstance = InstanceType<typeof ThemeBtn>
export { ThemeBtn as GiThemeBtn }

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