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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密前端传来的密码。
|
* 解密前端传来的密码。
|
||||||
*
|
*
|
||||||
|
|||||||
+16
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`。
|
||||||
@@ -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) 约定
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 环境变量 (命名必须以 VITE_ 开头)
|
||||||
|
|
||||||
|
# 接口前缀:真实后端(本项目的《03-接口设计》约定 /api/v1/...)
|
||||||
|
VITE_API_PREFIX = '/api/v1'
|
||||||
|
|
||||||
|
# 代理目标:本地 Spring Boot
|
||||||
|
VITE_API_BASE_URL = 'http://localhost:8080'
|
||||||
|
|
||||||
|
# 开发环境地址前缀
|
||||||
|
VITE_BASE = '/'
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 环境变量 (命名必须以 VITE_ 开头)
|
||||||
|
|
||||||
|
# 生产走同源反向代理
|
||||||
|
VITE_API_PREFIX = '/api/v1'
|
||||||
|
VITE_API_BASE_URL = 'https://api.tianbaotong.cn'
|
||||||
|
VITE_BASE = '/'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 环境变量 (命名必须以 VITE_ 开头)
|
||||||
|
|
||||||
|
VITE_API_PREFIX = '/api/v1'
|
||||||
|
VITE_API_BASE_URL = 'https://api-test.tianbaotong.cn'
|
||||||
|
VITE_BASE = '/'
|
||||||
+82
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
|
||||||
|
**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" />
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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 管理端(管理员 / 班主任)"
|
||||||
|
}
|
||||||
Generated
+6565
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface AreaItem {
|
||||||
|
label: string
|
||||||
|
code: string
|
||||||
|
children?: AreaItem[]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface CateTreeItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
pid: string | null
|
||||||
|
type: string | null
|
||||||
|
disabled: boolean
|
||||||
|
children: CateTreeItem[]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 中任意属性只能有一个
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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: '报名点'
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
Vendored
+78
@@ -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')
|
||||||
|
}
|
||||||
Vendored
+160
@@ -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}`]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user