Compare commits

..

90 Commits

Author SHA1 Message Date
vifo 7fdd93fc4b docs: 补充支付设计文档、store 字段设计文档及实施计划 2026-06-09 02:02:21 +08:00
vifo a79be07c52 docs: 新增 api-portal API 文档门户设计文档 2026-06-09 01:56:37 +08:00
vifo 7b2f3d77ca docs: 支付模块开发文档 + git 域名更新
- 新增 backend/design/支付模块架构概览.md
- 新增 backend/design/支付模块数据库设计.md (21张表 DDL)
- 新增 backend/design/支付模块接口设计.md
- git.dev.vifo.cc → git.vifo.cc 全局替换
2026-06-09 01:54:29 +08:00
vifo 1324a52049 docs(standards): 补充 Result<T> 统一响应类文档,修正 API 设计规范响应格式
- 新增 Result统一响应类.md 完整文档
- 修正 API设计规范.md 中响应字段与实际代码不一致的问题
- 错误码规范表按实际 ResultCode 枚举对齐
2026-06-08 16:01:33 +08:00
vifo d4a3bd5847 docs: 新增 AI 助手快速上手指南(面向开发者)
- 开工准备、常用指令模板、GitNexus 速查
- AGENTS.md 精简原则和检查清单
- 目录结构速查
2026-06-08 13:17:05 +08:00
vifo 8b2de75b5f refactor: 统一 remote 名称为 origin
- gitea-api.md: 删除 remote 名称差异表,统一使用 origin
- AI开发操作手册.md: push gitea main -> push origin main
2026-06-08 13:06:12 +08:00
vifo c7244b3b87 feat: 完善工单路由规则和 Gitea API 指南
- gitea-api.md: 补全 5 个仓库地址、工单路由表、URL 前缀映射、remote 名称、关闭 Issue API
- issue-workflow.md: 增加工单归属判断、跨仓库提工单流程、列出/关闭工单 API
2026-06-08 13:00:13 +08:00
vifo a7f3ee3565 refactor: 全局替换 spring-ai -> rui-framework
同步仓库名称变更,涉及 16 个文件 66 处引用:
- ai-skills: 菜单配置
- backend/guides: AI操作手册、环境配置、部署、gitnexus、opencode 工作流
- backend: 模块创建规则、通信规范、协作工作流、实施规范
- frontend: 收银设计、管理后台实施计划
- standards: 数据库设计规范
2026-06-08 12:56:39 +08:00
vifo 12a263c451 feat: 迁移 GitNexus 通用技能到全局文档仓库
- 从各仓库 .claude/skills/gitnexus/ 迁移 6 个技能
- 更新 ai-skills/README.md 说明目录结构
- 所有仓库的 GitNexus 技能完全相同,统一维护
2026-06-08 12:50:24 +08:00
vifo 2253ebea92 docs: update API design standards 2026-06-08 11:23:17 +08:00
vifo 76260b9458 docs(plan): mark Task 7 as completed (all 7 tasks done, E2E verified via static checks) 2026-06-08 11:23:17 +08:00
vifo 3d16902489 docs(plan): mark Task 6 as completed (form dialog created, commit 5231e50) 2026-06-08 11:23:17 +08:00
vifo fdc74784c2 docs(plan): mark Task 5 as completed (list page created, commit 3a64850) 2026-06-08 11:23:17 +08:00
vifo 8fed3a04be docs(plan): mark Task 4 as completed (route registered, commit e961bc5) 2026-06-08 11:23:17 +08:00
vifo a5863706dc docs(plan): mark Task 3 as completed (i18n added, commit 98741a0) 2026-06-08 11:23:17 +08:00
vifo d07a3f7f4b docs(plan): mark Task 2 as completed (sysAppService exported, commit 0b4b02f) 2026-06-08 11:23:17 +08:00
vifo 2152d0de42 docs(plan): mark Task 1 as completed (sysAppService created, commit 67d6686) 2026-06-08 11:23:17 +08:00
vifo cd2d68e60e docs(plan): add SysApp application integration management plan
- 7 个有序任务:Service → Index → i18n → Router → List → Form → E2E
- 任务粒度合适,每个聚焦单一文件/职责
- 包含完整依赖图、回滚计划、测试清单
- 严格遵循现有 plan 格式

对应工单 rui/rui-frontend#4
2026-06-08 11:23:17 +08:00
vifo f4761ae145 docs(spec): add SysApp application integration management design
- 为 rui/rui-frontend#4 工单编写设计规范
- 7 个文件变更:service 新建、index 改、router 改、locales 改、2 个 vue 新建
- 4 Tab 表单布局(基础信息/凭证信息/接口配置/高级)
- 敏感字段脱敏(appSecret/appKey/aesKey)
- certificates 字段 JSON 占位(等 rui/rui-framework#4 文件上传接口)

对应工单 rui/rui-frontend#4
2026-06-08 11:23:17 +08:00
vifo bb71263bdd docs: add superpowers design docs and plans 2026-06-08 11:23:17 +08:00
vifo b9d5b6d9f0 docs: 更新 Gitea API 文档,添加完整的 Issue 创建流程和示例 2026-06-08 11:23:17 +08:00
vifo 24a8643fb6 docs: 添加后端 API 工单 - 用户编辑接口密码字段处理优化 2026-06-08 11:23:17 +08:00
vifo 20d4a545b4 docs: 添加前后端智能协作方案实现计划 2026-06-08 11:23:17 +08:00
vifo 9957d85595 docs: 添加前后端智能协作方案设计文档 2026-06-08 11:23:17 +08:00
vifo f26c67368c docs(spec): 上传 API 增加 fileName + extract=zip 自动解压
- §7.1 补充 fileName (可选)、extract (bool)
- 响应统一为 Result<List<SysFileUploadVO>>:单文件也是长度 1 的数组
- §7.4 fileName 行为说明 (不传/传/校验)
- §7.5 extract=true (ZIP 解压):
  - 行为 + 路径规则 (bizType/zipBaseName/entryName)
  - 安全护栏 (总 entry ≤ 100, 单 entry ≤ maxSize, entry 名正则)
  - 请求/响应/订阅方事件示例
- 对应后端 commit: c4a5d5f
2026-06-07 23:17:14 +08:00
vifo 3aebe0b5a5 docs(spec/plan): FileBizType 改为工具类(非枚举),bizType 自由字符串
设计调整原因:上传服务是统一基础设施,强制枚举会要求「加新模块 = 改框架代码」。
改为 final class + normalize() 格式校验,业务模块自定 bizType 字符串即可。

- spec §4.2: 整个 FileBizType 章节重写(工具类 + 格式约束 + 设计意图)
- spec flow 5/11: 校验从 values() 改为 normalize()
- spec API 表 / 代码结构表 / 订阅方示例 同步更新
- plan Step 1.2/1.4 / 状态表 / 流程校验 / 发布器签名 同步更新
2026-06-07 22:44:12 +08:00
vifo 5f19332f06 docs(nacos): rui-service-storage 业务配置模板
- 包含 rui.file.* 全部业务配置
  active / default-max-size / biz-types (4个) / aliyun / tencent / local
- 服务端口 9400
- 严格遵循 docs/ai-skills/nacos-config-rules.md Rule 2
  仅放本服务特有业务配置,不含 Nacos 连接/编码/feign 等通用项

关联 Gitea #4 / 设计文档 §9.3
2026-06-07 22:23:27 +08:00
vifo a10b712919 docs(plan): 文件存储服务(rui-service-storage)实施计划
- 16 个可独立 commit 的子任务
- Task 1: 常量 + 枚举
- Task 2: sys_file DDL
- Task 3-9: 模块骨架 + Strategy 模式
- Task 10-12: 实体/Service/Controller/Event
- Task 13-14: 集成启动器 + 公共配置
- Task 15-16: 编译验证 + Gitea 关闭

关联 Gitea #4
2026-06-07 21:31:59 +08:00
vifo 66f0712486 docs(spec): 文件存储服务(rui-service-storage)设计文档
- 独立微服务 rui-service-storage 统一上传接口
- 支持阿里云 OSS / 腾讯云 COS / 本地 Strategy
- 内置 @AutoPermission 鉴权
- Redis pub/sub ON_UPLOAD / ON_FILE_DELETED 事件
- topic 常量集中在 rui-common-core/.../constants/MqTopicConstants
- 关联 Gitea #4
2026-06-07 21:25:31 +08:00
vifo b492c6224a docs(plan): wechat/alipay 凭证动态加载计划 - 完成状态同步
- 25 个 checkbox 全部勾选
- 添加实施状态(commit e3a441b)
- 补充实施完成报告
2026-06-07 19:17:10 +08:00
vifo 78e5ebc17e docs(plan): 修正 AlipayApiClient 字段映射
AlipayApiClient 构造需 (appId, privateKey, publicKey),
AppCredentialsVO 无这两个字段(按 spec 在 certificates JSON 数组里)。
AlipayApiClient.getAccessToken 当前抛 UnsupportedOperationException,
所以 privateKey/publicKey 暂用空串占位,cert 解析留作后续 Task。
2026-06-07 19:12:09 +08:00
vifo 2bfd1c0f4f docs(plan): wechat/alipay Provider 凭证动态加载改造计划
修复凭证烧死 bug:把 WeixinAuthenticationProvider /
AlipayAuthenticationProvider 改为持有 AppCredentialsCache,
buildToken 内部按 X-App-Id 头动态解析凭证并构造 API 客户端。

- 任务 1: WeixinAuthenticationProvider 改造
- 任务 2: AlipayAuthenticationProvider 改造
- 任务 3: OAuth2ServerConfig 清理
- 任务 4: AlipayApiClient 字段映射核对
2026-06-07 19:03:35 +08:00
vifo 4271f333b6 docs(specs): 更新已完成 spec 文档状态
- 2026-06-07-sys-app-management: 已批准,待实现 → 已实现
- 2026-06-07-multi-login-social-login: 已批准,待实现 → 已实现
- 2026-06-06-user-aggregate-query: 已批准 → 已实现

各 spec 对应的 plan 已结清/实施 commit 已落地,更新状态描述并补充实施情况指引。
2026-06-07 18:46:30 +08:00
vifo 4540af71ae docs(ai-skills): README 索引加 sql-deploy.md 入口 2026-06-07 18:25:34 +08:00
vifo eba4f07832 docs(standards): 增加 Feign 客户端注册规范章节
按用户提醒 + 本次实际踩坑记录:
- 项目用 rui-common-feign 自定义注册机制
- spring.factories 是 Feign 唯一注册渠道(@EnableFeignClients 包扫描不生效)
- 添加新 FeignClient 必须同步更新 spring.factories
- 漏写会导致运行时 NPE
2026-06-07 17:54:04 +08:00
vifo 3395f69b42 docs(standards): 增加 Result 返回规范章节
按用户反馈(i18n key 用 code 字符串,key 放 data 字段):
- 字段语义:error/Http、code/i18n key、message/默认中文、data/业务数据
- 调用规范表:成功/校验失败/未授权/无权限/数据不存在/降级/通用失败
- 重点:数据不存在用 failNotFound(ResultCode.DATA_NOT_FOUND, key)
- 禁止写法:Result.ok(null) 表示'未找到'(反直觉)
- i18n 配合示例:前端用 code 字段路由 i18n,data 字段做模板替换
2026-06-07 17:49:19 +08:00
vifo 22889afedc docs(spec): 用 appId 作为唯一标识(来自 X-App-Id 请求头)
原方案用 platform 作为缓存 key 错误:
- 多个租户都叫 platform=wechat,缓存 key 冲突
- platform 不是真正唯一标识

按用户反馈调整:
- 凭证查询/缓存全部用 appId
- appId 来源:请求头 X-App-Id
- §3.1 加 UNIQUE KEY uk_app_id (app_id) 约束
- §5.1 流程重写:从 X-App-Id 取 appId → 缓存 key = app:creds:{appId}
- §5.2/5.3 删缓存也改用 appId
- §7.1 OAuth2 改造:从 request.getHeader("X-App-Id") 取
- 删除 §1.2 中 'client_id 映射' 的旧说法
2026-06-07 17:08:32 +08:00
vifo c576053ab6 docs(spec): SysApp 简化为单密钥列 + 多证书 JSON 数组
按用户反馈调整:
- 删除:app_secret_text / app_secret_file_path / private/public_key 的 text+file_path
- 新增:certificates JSON(多 p12 证书,每项含 name/path/password)
- 简单凭证(app_id/app_secret/app_key/aes_key)保留单值 VARCHAR 列
- §2 原则 4 改为'简单文本用列 / 多证书用 JSON'
2026-06-07 17:02:06 +08:00
vifo 33690fe80b docs(spec): 第三方应用管理(SysApp)设计规格
- 新增 SysApp 实体:多租户第三方应用凭证管理
- 平台元数据 + 支付扩展 + AES key + 证书/文本分离
- 消费方:OAuth2ServerConfig Feign + Redis 30min 缓存
- 租户隔离:owner_type=PLATFORM/TENANT + client_id→tenant_id 映射
- 范围:不涉及 rui-service-user(user 模块)
2026-06-07 16:53:36 +08:00
vifo ae78e0f673 docs(nacos): 前缀 social → thirdparty,匹配 OAuth2ServerConfig 改动 2026-06-07 16:15:08 +08:00
vifo 856e16beff docs(nacos): 用 app.wechat/app.alipay 替代 social.* 配置结构
配合 OAuth2ServerConfig 重构:
- social.wechat.app-id/secret → app.wechat.app-id/secret
- social.alipay.app-id/private-key/public-key → app.alipay.*
- 新增 app.alipay.app-key (AlipayApiClient 预留扩展)
2026-06-07 16:13:09 +08:00
vifo 6df6e7ad0c docs(plan): 标记 Task 12 (编译验证) 已完成 (commits 74960af + baf0283)
附:实际执行包含修复 plan 的 supports 签名 bug (3 个 Provider)
2026-06-07 15:56:28 +08:00
vifo 365aa49cbd docs(plan): 标记 Task 11 (sys_oauth_client grant_types) 已完成 (commit 74960af) 2026-06-07 15:53:52 +08:00
vifo 792b10bd34 docs(plan): 标记 Task 10 (Nacos 社交登录配置) 已完成 (commit 2249b36) 2026-06-07 15:52:45 +08:00
vifo 2249b3649a docs(nacos): 添加社交登录配置 (rui-auth.yaml)
- 微信登录配置 (app-id, app-secret)
- 支付宝登录配置 (app-id, private-key, public-key)
- 用 env var 占位符支持容器化部署
2026-06-07 15:52:34 +08:00
vifo b3c66245e9 docs(plan): 标记 Task 9 (OAuth2 配置注册) 已完成 (commit 22c64bd)
附:实际还包含让原 no-arg 构造编译失败的修复
2026-06-07 15:51:25 +08:00
vifo eb99ae43cb docs(plan): 标记 Task 8 (支付宝登录框架) 已完成 (commit a4fcb95) 2026-06-07 15:49:17 +08:00
vifo c69c34ff25 docs(plan): 标记 Task 7 (微信登录框架) 已完成 (commit 337d189) 2026-06-07 15:47:31 +08:00
vifo 938302c164 docs(plan): 标记 Task 6 (短信登录框架) 已完成 (commit 89645d5) 2026-06-07 15:45:16 +08:00
vifo f1f4440be2 docs(plan): 标记 Task 5 (OAuth2 密码登录扩展) 已完成
- 3 个文件修改完成 (commit 2488bcf)
- 标注:Step 3 跳过 (loadUserByAccount 已支持 EMAIL)
- 关键修补:loadUserByUsername 增加 # 解码 (plan 漏掉的实现漏洞)
2026-06-07 15:42:36 +08:00
vifo 47ef4c6938 docs(plan): 标记 Task 4 (内部接口 EMAIL 支持) 已完成 (commit 27f4a00) 2026-06-07 15:38:35 +08:00
vifo 00c77529c5 docs(plan): 标记 Task 3 (数据访问层) 已完成
- UserSocialMapper/Service/Impl 创建完成 (commit c147e56)
- 标注 5 处实际执行的偏差(#prefix#、@EnableRedisCache、@Transactional、baseMapper、tenantId)
2026-06-07 15:37:38 +08:00
vifo 23823074f6 docs(plan): 标记 Task 2 (实体类调整) 已完成
- User 实体加 email、UserDetail 删 email、UserSocial 新建 (commit 1de6937)
- 添加实际执行说明:UserSocial 屏蔽 BaseEntity 不存在的审计字段
2026-06-07 15:34:26 +08:00
vifo 84b3bb601e docs(spec): 添加多方式登录与第三方登录设计规格 (已批准) 2026-06-07 15:32:26 +08:00
vifo 3c618b57bb docs(plan): 标记 Task 1 (数据库变更) 已完成
- Step 1-4 全部完成 (commit 6fd82fb)
- 添加实际执行说明:email 字段位置改为 AFTER username
2026-06-07 15:31:52 +08:00
vifo 9d0cffa86e docs(plan): 添加多方式登录与第三方登录实施计划
- 12个任务,覆盖数据库、实体、数据访问、认证逻辑、配置
- 详细的步骤和代码示例
- 包含编译验证和检查清单
2026-06-07 15:13:02 +08:00
vifo a4767ee3d0 docs(plan): 更新实施计划状态为已完成
- 所有20个任务已完成
- 编译验证通过
- 代码已提交
2026-06-06 17:10:40 +08:00
vifo 3c2fa877a6 docs(plan): 用户聚合查询实施计划
- 20个详细任务分解
- 包含依赖关系图
- 数据库变更、代码修改、缓存、测试全覆盖
- 风险评估和回滚计划
2026-06-06 13:32:44 +08:00
vifo de78c21799 docs(spec): 更新用户聚合查询设计规格
- 添加 phone 字段迁移到 uc_user 表的设计
- 新增统一认证接口 /user/inner/auth/load(POST)
- 支持 AccountType 枚举:USERNAME/PHONE/EMAIL
- 废弃旧的 loadByUsername 接口
- 添加数据库变更 SQL 脚本
2026-06-06 13:30:23 +08:00
vifo a8c164459a docs(spec): 用户聚合查询设计规格
- 方案B:后端聚合查询 + Redis缓存
- 新增聚合接口 /user/admin/user/{id}/aggregate
- 批量列表查询优化,避免N+1
- 缓存策略与失效机制设计
- 保持向后兼容
2026-06-06 13:14:13 +08:00
vifo 47d8af24f0 docs(standards): 添加 MyBatis Plus LambdaQueryWrapper 规范
- 在数据库规范中新增 MyBatis Plus 查询规范章节
- 明确优先使用 LambdaQueryWrapper,避免使用字符串字段名的 QueryWrapper
- 添加正反对照示例和优势说明

对应工单 #2
2026-06-06 12:42:24 +08:00
vifo 1a615c9f15 docs(api): 补充收银服务 Swagger/Knife4j/OpenAPI 访问地址 2026-06-06 10:25:12 +08:00
vifo 26f146f9a7 docs(api): 补充聚合启动器 API 文档地址
- 添加 rui-service-starter 的 /v3/api-docs 地址(端口 9399)
- 标注为开发调试用途

对应工单 #swagger-doc-update
2026-06-06 10:16:07 +08:00
vifo cfd2c44b80 docs(api): 补充 Swagger/Knife4j/OpenAPI 访问地址文档 2026-06-06 10:14:07 +08:00
vifo 33351d3261 Merge remote-tracking branch 'origin/main' 2026-06-05 11:57:47 +08:00
vifo 47945b245d docs(backend): 添加 BaseEntity 字段说明文档
- 说明所有业务实体必须继承的标准字段
- 包含字段类型、填充时机、数据库映射说明
- 提供 Java 和 SQL 使用示例
2026-06-05 11:55:44 +08:00
vifo 9f084720f8 feat(commit): 补充提交频率和推送规则
- 添加提交频率要求:每次修改后必须提交,禁止积攒
- 添加推送规则:常规开发不自动推送,累计超10个提交自动推送
- 远程名称使用 origin(默认)
2026-06-05 11:48:01 +08:00
vifo 044f9e31f3 清理暂时无用的 nacos 配置文件 2026-06-05 10:29:29 +08:00
vifo 5931d65806 fix(nacos): 清理 rui-service-starter.yaml 中的冗余通用配置
- 删除 nacos 连接、lifecycle、autoconfigure、multipart 等通用配置
- 仅保留 server.port 和模块管理业务配置
- 符合 Nacos 服务配置只放端口 + 业务配置的规则
2026-06-05 10:25:04 +08:00
vifo 57cc87a145 docs(template): 在 application-template.yml 中标注允许修改位置
- server.port 处增加【允许修改】注释
- rui-data import 处增加【允许修改】注释
2026-06-05 10:24:44 +08:00
vifo 386751b045 docs(ai-skills): 更新 README 目录,加入 Nacos 配置规范文档 2026-06-05 10:23:24 +08:00
vifo ee335fe90a docs(ai-skills): 添加 Nacos 配置与 application.yml 规范文档
- 明确 5 条配置规则
- 提供正确/错误示例
- 说明配置分层和同步流程
2026-06-05 10:20:12 +08:00
vifo 2277bca1ad feat(config): 简化 rui-service-starter 配置
- 基于 application-template.yml 模板
- 只保留端口、服务名和 modules 配置
- 移除多余的 feign/resilience4j/logging 配置

对应工单 #1
2026-06-05 08:42:57 +08:00
vifo 726fc229d6 feat(config): 完善 rui-service-starter Nacos 完整配置
- 基于 application-template.yml 创建完整配置
- 增加 modules 可用模块列表
- 对应工单 #1
2026-06-05 08:40:42 +08:00
vifo 1d6cb64ed3 feat(config): 简化 rui-service-starter Nacos 配置
- 只保留端口、服务名和 modules 配置
- 移除多余的 Spring Cloud 配置(已在本地 application.yml 中配置)

对应工单 #1
2026-06-05 08:35:00 +08:00
vifo 61fa206a45 feat(config): 完善 rui-service-starter Nacos 配置
- 基于 application-template.yml 模板创建完整配置
- 修改端口为 9399,名称为 rui-service-starter
- 增加 modules 可用模块列表配置

对应工单 #1
2026-06-05 08:32:19 +08:00
vifo 8506424c26 feat(config): 添加 rui-service-starter Nacos 配置模板
- 新增 rui-service-starter.yaml Nacos 配置模板
- 添加 modules 可用模块列表配置(供租户管理使用)

对应工单 #1
2026-06-05 08:30:28 +08:00
vifo 01f36acdd7 feat(config): 添加 rui-service-starter Nacos 配置模板和 modules 配置示例
- 新增 rui-service-starter.yaml Nacos 配置模板(含 modules 配置)
- 更新 application-template.yml 添加 modules 配置说明和示例

对应工单 #1
2026-06-05 08:23:23 +08:00
vifo cc46e13503 docs: 更新 SQL 脚本目录引用路径
- backend/guides/environment-setup.md: docs/init-database.sql → sql/init-database.sql
- frontend/admin-ui-icon-guide.md: docs/sql/update_menu_icon.sql → sql/update_menu_icon.sql
2026-06-05 06:56:53 +08:00
vifo 21cea34d18 fix(menus) 修改菜单配置文件路径为 data/menus 2026-06-05 06:18:07 +08:00
vifo f234845e17 fix: 修复配置文件路径,移除 conf/ 子目录引用 2026-06-04 14:25:05 +08:00
vifo 6e0919f1cd ci: 修复缓存卷冲突,添加 runner-default 缓存,隐藏 webhook token 2026-06-04 13:33:41 +08:00
vifo 8896b9aa58 feat(ai-skills): 添加 AI 全局技能库
创建 ai-skills/ 目录,包含所有 AI 助手共享的技能规范:

- gitea-api.md: Gitea API 使用指南

- issue-workflow.md: 工单处理标准流程

- menu-config.md: 菜单配置规范

- commit-standards.md: 提交规范

- README.md: 使用说明
2026-06-04 10:04:07 +08:00
vifo 32c7db7e49 Merge branch 'main' of ssh://git.dev.vifo.cc:222/rui/rui-docs 2026-06-04 09:38:52 +08:00
vifo cab0cf91c0 docs: 补充收银系统菜单数据结构
- 添加菜单 JSON 结构说明(code/type/icon/path/permission/buttons)
- 添加完整菜单配置 JSON(第 8 节)
- 添加权限标识汇总表
2026-06-04 09:38:52 +08:00
vifo 19de7e24ec docs: 迁移 spring-ai 通用文档到 rui-docs
从 docs-local 迁移以下文档:

- backend/guides/: AI开发环境配置、Nacos配置、GitNexus指南、OpenCode工作流等

- backend/templates/: Superpowers设计模板、计划模板、审查清单

- backend/config-templates/: 应用配置模板、Nacos配置

- backend/design/: 数据库表结构规划

- backend/specs/: 项目文档治理、MQ统一推送设计

- backend/: 代码分析报告、Feign分析报告、文档治理报告

- frontend/design/: Admin-UI分模块打包设计
2026-06-04 09:34:03 +08:00
vifo 2e38c53434 Merge branch 'main' of ssh://git.dev.vifo.cc:222/rui/rui-docs 2026-06-04 09:30:21 +08:00
vifo 87b7780dd6 docs: 补充收银系统 Git 仓库信息
- 添加项目仓库地址说明
- 明确 Issue 应提交到 rui-cashier 仓库
- 避免混淆 spring-ai 和 rui-cashier 仓库
2026-06-04 09:29:18 +08:00
vifo 0666c3ec7b docs: 迁移 spring-ai 通用文档到 rui-docs
- 添加 backend/项目实施规范.md
- 添加 backend/业务应用模块创建规则.md
- 添加 backend/guides/deployment-guide.md
- 添加 frontend/admin-ui-icon-guide.md
- 添加 frontend/admin-ui-status.md
- 更新 README.md 文档索引
2026-06-04 09:03:21 +08:00
vifo d0771597a6 init: 初始化项目文档中心
- 添加前端设计文档(rui-admin、收银系统、模块打包)
- 添加前端实施计划(收银后台功能完善)
- 添加通用规范(API、编码、数据库、前端开发规则)
- 建立文档索引和使用指南
2026-06-04 08:47:08 +08:00
276 changed files with 23502 additions and 25981 deletions
-36
View File
@@ -1,36 +0,0 @@
---
name: 前端 Bug 报告
title: "[BUG] "
labels: ["bug"]
about: 报告前端页面或组件的问题
---
## 问题描述
清晰描述问题现象
## 复现步骤
1. 进入 xxx 页面
2. 点击 xxx 按钮
3. 出现 xxx 错误
## 期望结果
描述正确的行为
## 实际结果
描述实际出现的问题
## 环境信息
- **浏览器**Chrome / Firefox / Safari / Edge
- **分辨率**1920x1080 / 移动端
- **项目**admin-ui / cashier-mobile / customer-mobile
## 截图
如有截图请粘贴
## 备注
-32
View File
@@ -1,32 +0,0 @@
---
name: 功能需求
title: "[FEATURE] "
labels: ["feature"]
about: 提出新的功能需求
---
## 需求描述
描述需要什么功能
## 使用场景
描述这个功能的使用场景
## 期望实现
描述期望的实现方式
## 优先级
- [ ] P0 - 阻塞
- [ ] P1 - 高
- [ ] P2 - 中
- [ ] P3 - 低
## 关联需求
- 需要后端接口支持:[创建 API-REQ Issue]
- 相关设计稿:
## 备注
-145
View File
@@ -1,145 +0,0 @@
name: 前端构建与检查
on:
push:
branches: [main, develop, 'feature/**', 'feat/**']
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
branches: [main, develop]
paths-ignore:
- '**.md'
- 'docs/**'
jobs:
lint-and-type-check:
name: 代码检查
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: 安装 pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 运行 ESLint
run: pnpm lint || true
- name: 运行类型检查
run: pnpm type-check || true
build-admin-ui:
name: 构建 Admin UI
runs-on: ubuntu-latest
needs: lint-and-type-check
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: 安装 pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 构建 Admin UI
run: pnpm build:admin
- name: 上传构建产物
uses: actions/upload-artifact@v4
with:
name: admin-ui-dist
path: admin-ui/dist
retention-days: 7
build-cashier-mobile:
name: 构建收银移动端
runs-on: ubuntu-latest
needs: lint-and-type-check
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: 安装 pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 构建收银移动端
run: pnpm build:cashier
- name: 上传构建产物
uses: actions/upload-artifact@v4
with:
name: cashier-mobile-dist
path: cashier-mobile/dist
retention-days: 7
build-customer-mobile:
name: 构建顾客移动端
runs-on: ubuntu-latest
needs: lint-and-type-check
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: 安装 pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 构建顾客移动端
run: pnpm build:customer
- name: 上传构建产物
uses: actions/upload-artifact@v4
with:
name: customer-mobile-dist
path: customer-mobile/dist
retention-days: 7
-7
View File
@@ -1,7 +0,0 @@
node_modules/
dist/
.DS_Store
*.local
.env.production
.cache/
*.log
-10
View File
@@ -1,10 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="openjdk-26" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
<component name="SshConsoleOptionsProvider">
<option name="myEncoding" value="UTF-8" />
<option name="myConnectionType" value="NONE" />
<option name="myConnectionId" value="" />
</component>
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/rui-frontend.iml" filepath="$PROJECT_DIR$/.idea/rui-frontend.iml" />
</modules>
</component>
</project>
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-11
View File
@@ -1,11 +0,0 @@
{
"name": "rui-frontend",
"description": "睿核科技前端开发会话 - 负责 rui-frontend 目录下的前端代码",
"scope": ["admin-ui/**", "cashier-mobile/**", "customer-mobile/**"],
"readonly": [],
"prompt": "你是 rui 前端开发助手。\n\n## 工作范围\n你只能修改 rui-frontend/ 目录下的前端代码。\n\n## 技术栈\n- Vue 3 + TypeScript\n- Element Plusadmin-ui\n- Vite\n- pnpm workspace\n\n## 编码规范\n1. 使用 `<script setup lang=\"ts\">`\n2. Props 和 Emit 必须定义类型\n3. API 服务层封装在 service/ 目录\n4. 状态管理使用 PiniaSetup Store 风格)\n5. 样式使用 UnoCSS + SCSS\n\n## 协作规则\n1. 需要后端接口时,提醒用户创建 Gitee Issue\n2. 不修改后端代码(backend/、app/\n3. 遵循 AGENTS.md 规范\n\n## 常用命令\n- pnpm dev:admin - 启动管理后台\n- pnpm build:admin - 构建管理后台\n- pnpm install - 安装依赖",
"git": {
"defaultBranch": "main",
"commitMessageFormat": "type(scope): 中文描述"
}
}
-99
View File
@@ -1,99 +0,0 @@
# AI 开发边界配置 - 前端仓库
> **警告**:本文件定义 AI 的开发边界,AI 必须严格遵守!
## 🎯 本仓库职责
**rui-frontend** 是睿核科技的前端工程集合,包含:
- `admin-ui` - 管理后台(Vue3 + TypeScript + Vite
- `cashier-mobile` - 收银移动端(技术栈待定,可能是 uni-app、React Native 等)
- `cashier-customer` - 顾客移动端(技术栈待定,可能是 uni-app、React Native 等)
## 🚫 绝对禁止
1. **禁止修改后端代码** - 本仓库只有前端代码
2. **禁止修改 API 接口定义** - 接口定义在后端仓库
3. **禁止直接访问数据库** - 必须通过 REST API
4. **禁止引入后端依赖** - 如 Spring, MyBatis 等
## ✅ 允许范围
```
rui-frontend/
├── admin-ui/ ✅ 可修改(Vue3 管理后台)
├── cashier-mobile/ ✅ 可修改(收银移动端)
├── cashier-customer/ ✅ 可修改(顾客移动端)
├── package.json ✅ 可修改(根配置)
└── pnpm-workspace.yaml ✅ 可修改
```
## 🔗 与后端通信规范
### 1. API 调用方式
- 使用 **Axios**、**Fetch** 或各框架提供的 HTTP 客户端发送请求
- 禁止直接引用后端 Java 类
### 2. 接口地址规范
```typescript
// 正确示例(axios
const API_BASE = '/api'; // 通过网关转发
const userApi = {
getList: () => axios.get(`${API_BASE}/user/list`),
create: (data) => axios.post(`${API_BASE}/user`, data)
};
// 正确示例(uni-app
const userApi = {
getList: () => uni.request({ url: `${API_BASE}/user/list` }),
create: (data) => uni.request({ url: `${API_BASE}/user`, method: 'POST', data })
};
```
### 3. 数据类型定义
- 前端自行定义 TypeScript 接口或各框架的类型定义
- 与后端 DTO 保持一致(但不直接引用)
```typescript
// 前端自定义类型
interface UserDTO {
id: number;
username: string;
// ...
}
```
## 📝 编码规范
### admin-ui(管理后台)
- **框架**Vue 3 + TypeScript + Vite
- **包管理器**:pnpm(强制使用)
- **组件规范**:组合式 APIsetup 语法)
### cashier-mobile / cashier-customer(移动端)
- **框架**uni-appVue3
- **支持平台**:小程序、H5、App
- **包管理器**pnpm(推荐)或 npm
- **UI 框架**uni-ui 或 uview-plus
### 通用规范
1. **代码风格**:遵循项目现有 ESLint/Prettier 配置
2. **HTTP 客户端**:统一封装请求拦截器(处理 Token、错误等)
3. **路由管理**:各项目自行管理前端路由
4. **状态管理**:根据框架选择(Pinia、Vuex、Redux、Zustand 等)
## 🔔 当需要后端支持时
如果前端需要新的 API 接口:
1. **不要自己创建后端代码**
2. 在后端仓库的 Issue 中提需求
3. 等待后端提供接口后再联调
## 📞 协作方式
- **前端 AI**:只负责 rui-frontend 仓库
- **后端 AI**:负责 spring-ai 仓库提供 API
- **沟通方式**:通过 Gitea Issue / 钉钉通知
---
> **最后提醒**:AI 必须严格限制在本仓库范围内开发,禁止跨仓库修改代码!
-1
View File
@@ -1 +0,0 @@
# CI 测试 - 2026-06-04 06:20:17
-1
View File
@@ -1 +0,0 @@
调试测试 07:34:22
-1
View File
@@ -1 +0,0 @@
钉钉消息测试 - 2026-06-04 06:37:23
-1
View File
@@ -1 +0,0 @@
最终测试 2026-06-04 07:30:33
-1
View File
@@ -1 +0,0 @@
# Gitea Test
-1
View File
@@ -1 +0,0 @@
JSON序列化测试 07:09:53
-1
View File
@@ -1 +0,0 @@
Markdown测试 07:28:00
+86 -168
View File
@@ -1,203 +1,121 @@
# rui-frontend # Rui-Docs
> 睿核科技前端项目集合 > 睿核科技项目文档中心 —— 前后端共享的独立文档仓库
## 项目结构 ## 📁 目录结构
``` ```
rui-frontend/ rui-docs/
├── admin-ui/ # 管理后台(Vue 3 + Element Plus ├── README.md # 本文档
├── src/ ├── standards/ # 通用规范(前后端共享)
│ ├── package.json │ ├── API设计规范.md
── ... ── 前端开发规则.md
├── cashier-mobile/ # 收银系统移动端(占位) │ ├── 数据库设计规范分析.md
── src/ ── coding-standards.md
├── package.json ├── backend/ # 后端相关文档
── ... ── design/ # 设计文档
├── customer-mobile/ # 收银系统顾客端(占位) │ ├── specs/ # 规格说明
│ ├── src/ │ ├── guides/ # 操作指南
│ ├── package.json │ ├── AI开发操作手册.md
│ └── ... │ └── deployment-guide.md
├── package.json # 根 package.json │ ├── 模块间通信规范.md
├── pnpm-workspace.yaml # pnpm 工作区配置 │ ├── 跨团队协作工作流.md
└── README.md # 本文档 │ ├── 项目实施规范.md
│ └── 业务应用模块创建规则.md
└── frontend/ # 前端相关文档
├── design/ # 设计文档
├── specs/ # 规格说明
├── plans/ # 实施计划
├── admin-ui-icon-guide.md
└── admin-ui-status.md
``` ```
## 技术栈 ## 🔗 使用方式
- **构建工具**Vite ### 作为 Git Submodule
- **框架**Vue 3 + TypeScript
- **UI 组件**Element Plusadmin-ui/ 待定(移动端)
- **状态管理**Pinia
- **包管理器**pnpm
- **工作区**pnpm workspace
## 快速开始 在代码仓库中添加文档子模块:
### 环境要求
- Node.js >= 18.0.0
- pnpm >= 8.0.0
### 安装依赖
```bash ```bash
# 安装所有项目依赖 # 添加 submodule
pnpm install git submodule add ssh://git@git.vifo.cc:222/rui/rui-docs.git docs
# 或只安装某个项目 # 更新到最新
cd admin-ui git submodule update --remote
pnpm install
# 初始化(新克隆时)
git submodule update --init --recursive
``` ```
### 开发模式 ### 独立查看
```bash ```bash
# 启动管理后台 git clone ssh://git@git.vifo.cc:222/rui/rui-docs.git
pnpm dev:admin cd rui-docs
# 启动收银移动端
pnpm dev:cashier
# 启动顾客端
pnpm dev:customer
``` ```
### 构建 ## 📋 文档索引
```bash ### 前端设计文档(frontend/design/
# 构建所有项目
pnpm build:all
# 构建单个项目 | 文档 | 说明 | 更新日期 |
pnpm build:admin |------|------|----------|
pnpm build:cashier | `rui-admin功能设计文档.md` | rui-admin 管理后台功能模块设计 | 2026-06-03 |
pnpm build:customer | `cashier-design.md` | 收银系统(POS)整体架构设计 | 2026-06-03 |
``` | `admin-ui-module-build-design.md` | Admin-UI 分模块打包功能设计 | 2026-06-04 |
## 项目说明 ### 前端实施计划(frontend/plans/
### admin-ui | 文档 | 说明 | 更新日期 |
|------|------|----------|
| `cashier-admin-implementation.md` | 收银系统后台管理功能完善实施计划 | 2026-06-04 |
管理后台系统,支持多系统切换(收银、超管、运营)。 ### 后端文档(backend/
- **技术栈**Vue 3 + Element Plus + TypeScript | 文档 | 说明 | 更新日期 |
- **端口**3000 |------|------|----------|
- **构建命令**`pnpm build:admin` | `模块间通信规范.md` | 微服务模块间通信规范(Feign/REST | 2026-06-04 |
| `跨团队协作工作流.md` | 多团队/多 AI 协作流程规范 | 2026-06-04 |
| `项目实施规范.md` | 项目整体实施规范与架构说明 | 2026-06-04 |
| `业务应用模块创建规则.md` | 业务模块(app/)创建标准 | 2026-06-04 |
| `guides/AI开发操作手册.md` | AI 开发环境配置与操作指南 | 2026-06-04 |
| `guides/deployment-guide.md` | 项目部署指南 | 2026-06-04 |
### cashier-mobile(待开发 ### 前端文档(frontend/
收银系统移动端,供店员使用。 | 文档 | 说明 | 更新日期 |
|------|------|----------|
| `admin-ui-icon-guide.md` | Admin-UI 图标使用指南 | 2026-06-04 |
| `admin-ui-status.md` | Admin-UI 状态管理文档 | 2026-06-04 |
- **技术栈**:待定(UniApp / React Native / Flutter ### 通用规范(standards/
- **功能**:开台、点餐、结账、退款
### customer-mobile(待开发) | 文档 | 说明 |
|------|------|
| `API设计规范.md` | RESTful API 设计规范 |
| `前端开发规则.md` | 前端编码规范、目录结构、命名约定 |
| `数据库设计规范分析.md` | 数据库设计规范与约束 |
| `coding-standards.md` | 通用编码规范(Java/TypeScript |
收银系统顾客端,供顾客扫码点餐、支付。 ## 🔄 文档维护规范
- **技术栈**:待定(微信小程序 / H5) 1. **文档即代码**:所有文档使用 Markdown 格式,纳入版本控制
- **功能**:扫码点餐、在线支付、订单查询 2. **单一数据源**:本文档仓库是唯一的文档源头,代码仓库通过 submodule 引用
3. **变更追溯**:文档修改需通过 PR/MR 流程,保留修改历史
4. **定期同步**:代码仓库的 submodule 应定期更新到最新文档版本
## 开发规范 ## 📝 新增文档流程
### 代码规范 1. 在本文档仓库的对应目录下创建 `.md` 文件
2. 提交并推送到远程
3. 在代码仓库中执行 `git submodule update --remote` 获取更新
- 使用 TypeScript ## 👥 参与人员
- ESLint + Prettier 自动格式化
- 组件名使用 PascalCase
- 组合式函数使用 useXxx 命名
### Git 提交规范 - **前端开发**:关注 `frontend/` 目录
- **后端开发**:关注 `backend/` 目录
- **架构师/全栈**:关注 `standards/` 目录
``` ---
type(scope): 中文描述
示例: > 📧 文档问题请联系项目维护者
feat(admin-ui): 添加用户管理页面
fix(cashier): 修复结账金额计算错误
docs: 更新 README
```
type 类型:
- `feat`:新功能
- `fix`:修复
- `docs`:文档
- `style`:格式(不影响代码运行)
- `refactor`:重构
- `test`:测试
- `chore`:构建/工具
### 目录规范
```
{project}/src/
├── api/ # API 接口定义
├── assets/ # 静态资源
├── components/ # 公共组件
│ └── common/ # 通用组件
├── composables/ # 组合式函数
├── layouts/ # 布局组件
├── router/ # 路由配置
├── stores/ # Pinia 状态管理
├── styles/ # 全局样式
├── types/ # TypeScript 类型定义
├── utils/ # 工具函数
└── views/ # 页面视图
```
## 与后端协作
### API 接口
后端接口文档:`http://{backend-host}/doc.html`
前端通过 Axios 调用后端 API,基地址在 `.env` 文件中配置:
```
# .env.development
VITE_API_BASE_URL=http://localhost:8080
```
### 跨团队协作
前端与后端通过 **Gitee Issue** 进行协作:
1. 前端需要新接口 → 在后端仓库创建 `[API-REQ]` Issue
2. 后端实现后 → 在 Issue 回复 Swagger 地址
3. 前端根据 Swagger 开发/联调
详见后端仓库文档:`docs/cross-team-workflow.md`
## CI/CD
### 构建流程
```
code push → GitHub Actions → build → deploy
```
### 部署环境
| 环境 | 分支 | 域名 |
|------|------|------|
| 开发 | develop | dev-frontend.vifo.cc |
| 测试 | release/* | test-frontend.vifo.cc |
| 生产 | main | admin.vifo.cc |
## 相关仓库
- **后端框架**`spring-ai`backend/ + app/
- **接口文档**`http://{backend-host}/doc.html`
## 贡献指南
1. Fork 本仓库
2. 创建功能分支:`git checkout -b feat/xxx`
3. 提交代码:`git commit -m "feat: xxx"`
4. 推送分支:`git push origin feat/xxx`
5. 创建 Pull Request
## 许可证
MIT
-1
View File
@@ -1 +0,0 @@
再次测试 2026-06-04 07:31:43
-1
View File
@@ -1 +0,0 @@
最终验证 2026-06-04 07:43:19
-1
View File
@@ -1 +0,0 @@
v2测试 07:13:54
-6
View File
@@ -1,6 +0,0 @@
# 管理后台默认租户编号
# 该值会在每个 HTTP 请求的 X-Tenant-Id 请求头中透传至后端
VITE_TENANT_ID=1
# OAuth2 客户端密钥(client_id:client_secret 的 base64 编码)
# 用于 /oauth2/token 接口的 Basic 认证
VITE_OAUTH2_CLIENT_SECRET=cnVpLWNsaWVudDpydWktc2VjcmV0
-18
View File
@@ -1,18 +0,0 @@
# 管理后台 PC
Vue 3 + TypeScript + Vite + Element Plus + UnoCSS
## 启动
```bash
pnpm install
pnpm dev
```
访问 `http://localhost:3000`
## 构建
```bash
pnpm build
```
-20
View File
@@ -1,20 +0,0 @@
{
"key": "cashier",
"name": "收银系统",
"description": "面向收银场景的管理后台",
"modules": ["system", "user", "cms", "cashier"],
"login": {
"component": "Cashier",
"showTenantInput": true,
"title": "睿核收银",
"subtitle": "门店管理系统"
},
"dashboard": {
"component": "Cashier",
"title": "收银数据概览"
},
"theme": {
"primaryColor": "#1677ff",
"title": "睿核收银"
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"key": "default",
"name": "默认系统",
"description": "开发测试用,包含所有模块",
"modules": ["system", "user", "order", "cms", "marketing", "demo", "cashier"],
"login": {
"component": "Default",
"showTenantInput": true,
"title": "睿核通用平台",
"subtitle": "管理后台登录"
},
"dashboard": {
"component": "Default",
"title": "数据概览"
},
"theme": {
"primaryColor": "#1677ff",
"title": "睿核通用平台"
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"key": "super",
"name": "超级管理后台",
"description": "超级租户专用,包含租户管理",
"modules": ["system", "user"],
"login": {
"component": "Super",
"showTenantInput": false,
"title": "睿核平台管理",
"subtitle": "超级管理员登录"
},
"dashboard": {
"component": "Super",
"title": "平台运营概览"
},
"theme": {
"primaryColor": "#722ed1",
"title": "睿核平台管理"
}
}
-17
View File
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>睿核通用平台 — 管理后台</title>
<style>
* { margin: 0; padding: 0; }
html, body, #app { height: 100%; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-44
View File
@@ -1,44 +0,0 @@
{
"name": "admin-ui",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"dev:cashier": "vite --port 3000 -- --system=cashier",
"dev:super": "vite --port 3000 -- --system=super",
"build": "vite build",
"build:cashier": "vite build -- --system=cashier",
"build:super": "vite build -- --system=super",
"build:admin": "vite build -- --system=admin",
"build:all": "pnpm build:cashier && pnpm build:super && pnpm build:admin",
"type-check": "vue-tsc",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3",
"@iconify-json/tabler": "^1.2.35",
"@iconify/vue": "^5.0.1",
"axios": "^1.7",
"element-plus": "^2.9",
"pinia": "^2.2",
"vue": "^3.5",
"vue-i18n": "^10.0",
"vue-router": "^4.4"
},
"devDependencies": {
"@antfu/eslint-config": "^3.11",
"@unocss/preset-attributify": "^0.65",
"@unocss/preset-icons": "^0.65",
"@unocss/preset-uno": "^0.65",
"@vitejs/plugin-vue": "^5.2",
"@vue/tsconfig": "^0.7",
"eslint": "^9.15",
"sass": "^1.81",
"typescript": "~5.6",
"unocss": "^0.65",
"unplugin-auto-import": "^0.18",
"unplugin-vue-components": "^0.28",
"vite": "^6.0",
"vue-tsc": "^2.1"
}
}
-5948
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true
unrs-resolver: true
vue-demi: true
@@ -1,125 +0,0 @@
import type { Plugin, ViteDevServer } from 'vite'
import fs from 'fs'
import path from 'path'
import type { BuildConfig } from '../src/types/system-config'
/**
* 模块路由映射表
*/
const moduleRouteMap: Record<string, string> = {
system: "import { systemRoutes } from '@/router/modules/system'",
user: "import { userRoutes } from '@/router/modules/user'",
order: "import { orderRoutes } from '@/router/modules/order'",
cms: "import { cmsRoutes } from '@/router/modules/cms'",
marketing: "import { marketingRoutes } from '@/router/modules/marketing'",
demo: "import { demoRoutes } from '@/router/modules/demo'",
cashier: "import { cashierRoutes } from '@/router/modules/cashier'",
}
/**
* 生成路由代码
*/
function generateRoutesCode(config: BuildConfig): string {
const imports = config.modules
.map((module) => moduleRouteMap[module])
.filter(Boolean)
.join('\n')
const routeArrays = config.modules
.map((module) => {
const varName = '...' + module + 'Routes'
return varName
})
.join(', ')
return `
import { coreRoutes } from '@/router/modules/core'
${imports}
const routes = [
...coreRoutes.slice(0, 1), // login route
{
path: '/',
component: () => import('@/layout/DefaultLayout.vue'),
redirect: '/dashboard',
children: [
...coreRoutes[1].children, // dashboard, profile, settings
${routeArrays},
],
},
]
export default routes
`
}
/**
* 生成系统配置代码
*/
function generateConfigCode(config: BuildConfig): string {
return `export default ${JSON.stringify(config, null, 2)}`
}
/**
* Vite 模块构建插件
*/
export default function moduleBuildPlugin(): Plugin {
let config: BuildConfig | null = null
let systemKey = 'default'
return {
name: 'vite-plugin-module-build',
enforce: 'pre',
config(_config, { command }) {
// 从命令行参数读取系统标识
const args = process.argv
const systemArg = args.find((arg) => arg.startsWith('--system='))
if (systemArg) {
systemKey = systemArg.replace('--system=', '')
}
// 读取配置文件
const configPath = path.resolve(process.cwd(), 'build-config', `${systemKey}.json`)
if (!fs.existsSync(configPath)) {
throw new Error(`System config not found: ${configPath}`)
}
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
// 修改构建输出目录
return {
build: {
outDir: `dist/${config.key}`,
},
}
},
resolveId(id) {
if (id === 'virtual:generated-routes' || id === 'virtual:system-config') {
return id
}
return null
},
load(id) {
if (!config) return null
if (id === 'virtual:generated-routes') {
return generateRoutesCode(config)
}
if (id === 'virtual:system-config') {
return generateConfigCode(config)
}
return null
},
configureServer(server: ViteDevServer) {
// 开发模式下,当配置文件变更时重启服务
const configPath = path.resolve(server.config.root, 'build-config', `${systemKey}.json`)
server.watcher.add(configPath)
},
}
}
-69
View File
@@ -1,69 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import systemConfig from 'virtual:system-config'
const app = useAppStore()
const locale = computed(() => app.lang === 'zh-CN' ? zhCn : en)
onMounted(() => {
// 设置页面标题
document.title = systemConfig.theme.title
// 设置主题色
const el = document.documentElement
el.style.setProperty('--el-color-primary', systemConfig.theme.primaryColor)
})
</script>
<template>
<el-config-provider :locale="locale">
<router-view />
<!-- 全屏退出角标 -->
<div v-if="app.pageFullscreen" class="fs-exit" @click="app.togglePageFullscreen()">
<el-icon :size="18"><Close /></el-icon>
</div>
</el-config-provider>
</template>
<style>
:root { --el-bg-color-page: #f5f6fa; }
html.dark { --el-bg-color-page: #111; color-scheme: dark; }
html.dark body { background: #111; color: #e5e7eb; }
.fullscreen-main {
position: fixed !important; inset: 0 !important; z-index: 100 !important;
background: #f5f6fa !important; padding: 20px !important; overflow: auto !important;
}
html.dark .fullscreen-main { background: #111 !important; }
.fs-exit {
position: fixed; top: 16px; right: 16px; z-index: 200;
width: 36px; height: 36px; border-radius: 50%;
background: rgba(0,0,0,0.4); color: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.2s;
}
.fs-exit:hover { background: rgba(0,0,0,0.6); }
/* ======================== rui-dialog 全局样式规则 ======================== */
/**
* 所有使用 el-dialog 的组件必须添加 class="rui-dialog"
* 规则说明:
* 1. 内容区域最大高度 60vh,超出滚动
* 2. 底部 footer 固定不参与滚动
* 3. 用法:<el-dialog class="rui-dialog" ...>
*/
.rui-dialog .el-dialog__body {
max-height: 60vh;
overflow-y: auto;
padding: 20px;
}
.rui-dialog .el-dialog__footer {
border-top: 1px solid var(--el-border-color-lighter);
padding: 12px 20px;
background: var(--el-bg-color);
}
</style>
-88
View File
@@ -1,88 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
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 mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
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 setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
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, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
-77
View File
@@ -1,77 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
CashierTablePage: typeof import('./components/CashierTablePage.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
IconPicker: typeof import('./components/IconPicker.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
RuiIcon: typeof import('./components/RuiIcon.vue')['default']
RuiTable: typeof import('./components/RuiTable.vue')['default']
TagsBar: typeof import('./components/TagsBar.vue')['default']
ThemeDrawer: typeof import('./components/ThemeDrawer.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
title: string
columns: any[]
loading?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'add'): void
(e: 'refresh'): void
}>()
const searchForm = ref({})
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">{{ title }}</h2>
<div class="toolbar mb-4">
<el-button type="primary" @click="emit('add')">
新增
</el-button>
<el-button @click="emit('refresh')">
刷新
</el-button>
</div>
<el-table
v-loading="loading"
:data="[]"
stripe
border
style="width: 100%"
>
<el-table-column
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || 'left'"
/>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small">编辑</el-button>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
.toolbar {
display: flex;
gap: 8px;
}
</style>
-211
View File
@@ -1,211 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Icon } from '@iconify/vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import RuiIcon from './RuiIcon.vue'
const visible = defineModel<boolean>()
const emit = defineEmits<{ select: [icon: string] }>()
const search = ref('')
const tab = ref<'ep' | 'tabler' | 'svg' | 'other'>('ep')
// ==================== Element Plus 图标 ====================
const epIcons = Object.keys(ElementPlusIconsVue)
const epFiltered = computed(() => {
if (!search.value) return epIcons
return epIcons.filter(i => i.toLowerCase().includes(search.value.toLowerCase()))
})
// ==================== Tabler Icons (通过 Iconify) ====================
// Tabler 图标集前缀为 "tabler:"
// 这里列出常用的 Tabler 图标,实际使用时可以从 @iconify-json/tabler 获取完整列表
const tablerIcons = [
'tabler:home', 'tabler:settings', 'tabler:user', 'tabler:users',
'tabler:file', 'tabler:folder', 'tabler:mail', 'tabler:bell',
'tabler:search', 'tabler:plus', 'tabler:edit', 'tabler:trash',
'tabler:check', 'tabler:x', 'tabler:arrow-left', 'tabler:arrow-right',
'tabler:arrow-up', 'tabler:arrow-down', 'tabler:chevron-left',
'tabler:chevron-right', 'tabler:chevron-up', 'tabler:chevron-down',
'tabler:menu', 'tabler:dots', 'tabler:dots-vertical',
'tabler:refresh', 'tabler:reload', 'tabler:download', 'tabler:upload',
'tabler:share', 'tabler:link', 'tabler:external-link',
'tabler:lock', 'tabler:lock-open', 'tabler:key', 'tabler:shield',
'tabler:eye', 'tabler:eye-off', 'tabler:copy', 'tabler:clipboard',
'tabler:calendar', 'tabler:clock', 'tabler:timer', 'tabler:history',
'tabler:chart-bar', 'tabler:chart-line', 'tabler:chart-pie',
'tabler:trending-up', 'tabler:trending-down', 'tabler:activity',
'tabler:dashboard', 'tabler:gauge', 'tabler:speedboat',
'tabler:building', 'tabler:building-store', 'tabler:building-bank',
'tabler:briefcase', 'tabler:certificate', 'tabler:award',
'tabler:star', 'tabler:star-off', 'tabler:heart', 'tabler:thumb-up',
'tabler:message', 'tabler:messages', 'tabler:notification',
'tabler:device-desktop', 'tabler:device-mobile', 'tabler:devices',
'tabler:wifi', 'tabler:bluetooth', 'tabler:cloud', 'tabler:database',
'tabler:server', 'tabler:box', 'tabler:package', 'tabler:truck',
'tabler:map', 'tabler:map-pin', 'tabler:location',
'tabler:phone', 'tabler:video', 'tabler:camera', 'tabler:photo',
'tabler:music', 'tabler:volume', 'tabler:microphone',
'tabler:book', 'tabler:bookmark', 'tabler:flag', 'tabler:tag',
'tabler:tags', 'tabler:label', 'tabler:pin',
'tabler:brush', 'tabler:palette', 'tabler:color-swatch',
'tabler:tools', 'tabler:tool', 'tabler:hammer', 'tabler:wrench',
'tabler:bug', 'tabler:code', 'tabler:terminal', 'tabler:prompt',
'tabler:git-branch', 'tabler:git-commit', 'tabler:git-merge',
'tabler:layout', 'tabler:layout-grid', 'tabler:layout-list',
'tabler:template', 'tabler:components', 'tabler:puzzle',
'tabler:adjustments', 'tabler:slider', 'tabler:toggle-left',
'tabler:filter', 'tabler:sort-ascending', 'tabler:sort-descending',
'tabler:zoom-in', 'tabler:zoom-out', 'tabler:maximize', 'tabler:minimize',
'tabler:login', 'tabler:logout', 'tabler:user-plus', 'tabler:user-minus',
'tabler:user-check', 'tabler:user-x', 'tabler:user-circle',
'tabler:id', 'tabler:passport', 'tabler:fingerprint',
'tabler:credit-card', 'tabler:wallet', 'tabler:cash', 'tabler:coin',
'tabler:receipt', 'tabler:file-invoice', 'tabler:report',
'tabler:printer', 'tabler:scan', 'tabler:qrcode',
'tabler:shopping-cart', 'tabler:basket', 'tabler:bag',
'tabler:rocket', 'tabler:flame', 'tabler:bolt', 'tabler:bulb',
'tabler:help', 'tabler:info-circle', 'tabler:alert-circle',
'tabler:alert-triangle', 'tabler:circle-check', 'tabler:circle-x',
'tabler:ban', 'tabler:plus-circle', 'tabler:minus-circle',
'tabler:exchange', 'tabler:transfer', 'tabler:send', 'tabler:mail-forward',
'tabler:inbox', 'tabler:archive', 'tabler:trash-off',
'tabler:undo', 'tabler:redo', 'tabler:rotate', 'tabler:rotate-clockwise',
'tabler:layers', 'tabler:layer', 'tabler:stack',
'tabler:affiliate', 'tabler:apps', 'tabler:grid',
'tabler:world', 'tabler:globe', 'tabler:language',
'tabler:sun', 'tabler:moon', 'tabler:brightness',
'tabler: Temperature', 'tabler:wind', 'tabler:droplet',
'tabler:leaf', 'tabler:plant', 'tabler:tree',
'tabler:anchor', 'tabler:ship', 'tabler:plane',
'tabler:train', 'tabler:bus', 'tabler:car',
'tabler:bike', 'tabler:walk', 'tabler:run',
'tabler:school', 'tabler:book-2', 'tabler:notebook',
'tabler:notes', 'tabler:clipboard-list', 'tabler:clipboard-check',
'tabler:list', 'tabler:list-check', 'tabler:list-details',
'tabler:checklist', 'tabler:task', 'tabler:tasks',
'tabler:kanban', 'tabler:gantt', 'tabler:timeline',
'tabler:chart-arcs', 'tabler:chart-dots', 'tabler:chart-circles',
'tabler: pollution', 'tabler:recycle', 'tabler:eco',
'tabler:binary', 'tabler:cpu', 'tabler:memory',
'tabler:network', 'tabler:router', 'tabler:firewall',
'tabler:shield-check', 'tabler:shield-lock', 'tabler:shield-off',
'tabler:key-off', 'tabler:lock-access',
'tabler:subtask', 'tabler:parentheses', 'tabler:braces',
'tabler:quote', 'tabler:blockquote',
'tabler:math', 'tabler:calculator', 'tabler:abacus',
'tabler:clock-hour-1', 'tabler:clock-hour-2', 'tabler:clock-hour-3',
'tabler:calendar-event', 'tabler:calendar-plus', 'tabler:calendar-minus',
'tabler:clock-play', 'tabler:clock-pause', 'tabler:clock-stop',
'tabler:alarm', 'tabler:stopwatch', 'tabler:hourglass',
]
const tablerFiltered = computed(() => {
if (!search.value) return tablerIcons
return tablerIcons.filter(i => i.toLowerCase().includes(search.value.toLowerCase()))
})
// ==================== SVG 示例 ====================
const svgIcons = [
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>',
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>',
'<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>',
]
function select(icon: string) {
emit('select', icon)
visible.value = false
}
</script>
<template>
<el-dialog class="rui-dialog" v-model="visible" title="选择图标" width="800px">
<el-tabs v-model="tab">
<!-- Element Plus 图标 -->
<el-tab-pane label="Element Plus" name="ep">
<el-input v-model.trim="search" placeholder="搜索图标名..." clearable class="mb-3">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="icon-grid">
<div v-for="icon in epFiltered" :key="icon" class="icon-cell" @click="select(icon)">
<RuiIcon :icon="icon" :size="24" />
<span class="text-xs mt-1 truncate w-full text-center">{{ icon }}</span>
</div>
</div>
<div v-if="epFiltered.length === 0" class="text-center text-gray-400 py-8">
未找到匹配的图标
</div>
</el-tab-pane>
<!-- Tabler Icons -->
<el-tab-pane :label="`Tabler (${tablerIcons.length})`" name="tabler">
<el-input v-model.trim="search" placeholder="搜索 Tabler 图标..." clearable class="mb-3">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="icon-grid">
<div v-for="icon in tablerFiltered" :key="icon" class="icon-cell" @click="select(icon)">
<Icon :icon="icon" width="24" height="24" />
<span class="text-xs mt-1 truncate w-full text-center">{{ icon.replace('tabler:', '') }}</span>
</div>
</div>
<div v-if="tablerFiltered.length === 0" class="text-center text-gray-400 py-8">
未找到匹配的图标
</div>
</el-tab-pane>
<!-- SVG -->
<el-tab-pane label="SVG" name="svg">
<div class="icon-grid">
<div v-for="(svg, i) in svgIcons" :key="i" class="icon-cell" @click="select(svg)">
<RuiIcon :icon="svg" :size="24" />
<span class="text-xs mt-1">SVG {{ i + 1 }}</span>
</div>
</div>
</el-tab-pane>
<!-- 图片/URL -->
<el-tab-pane label="图片/URL" name="other">
<div class="p-4 text-center text-gray-400 text-sm">
粘贴图片 URL 到输入框即可使用
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<style scoped>
.icon-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 8px;
max-height: 50vh;
overflow: auto;
}
.icon-cell {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 4px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
aspect-ratio: 1;
}
.icon-cell:hover {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
@media (max-width: 768px) {
.icon-grid {
grid-template-columns: repeat(6, 1fr);
}
}
</style>
-41
View File
@@ -1,41 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Icon } from '@iconify/vue'
const props = defineProps<{
icon?: string // ep icon name, iconify name, svg string, font class, or image url
size?: number
color?: string
}>()
const type = computed<'ep' | 'iconify' | 'svg' | 'font' | 'img' | 'none'>(() => {
if (!props.icon) return 'none'
if (props.icon.startsWith('<svg') || props.icon.startsWith('<?xml')) return 'svg'
if (props.icon.startsWith('http') || props.icon.startsWith('/')) return 'img'
if (props.icon.includes(' ')) return 'font'
// Iconify 格式:包含冒号,如 tabler:settings
if (props.icon.includes(':')) return 'iconify'
return 'ep'
})
const style = computed(() => ({
fontSize: (props.size || 16) + 'px',
color: props.color,
width: (props.size || 16) + 'px',
height: (props.size || 16) + 'px',
}))
</script>
<template>
<span v-if="type === 'ep'" class="rui-icon ep"><el-icon :size="size" :color="color"><component :is="icon" /></el-icon></span>
<span v-else-if="type === 'iconify' && icon" class="rui-icon iconify" :style="style"><Icon :icon="icon" :width="size || 16" :height="size || 16" :color="color" /></span>
<span v-else-if="type === 'svg'" class="rui-icon svg" :style="style" v-html="icon" />
<span v-else-if="type === 'font'" class="rui-icon font" :style="style" :class="icon" />
<img v-else-if="type === 'img'" class="rui-icon img" :src="icon" :style="style" />
</template>
<style scoped>
.rui-icon { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; }
.rui-icon.svg :deep(svg) { width: 100%; height: 100%; }
.rui-icon.img { object-fit: contain; }
</style>
-752
View File
@@ -1,752 +0,0 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { RefreshRight, ArrowDown, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
/**
* 数据类型,用于自动格式化
*/
export type DataType =
| 'dateTime' // 日期时间:yyyy-MM-dd HH:mm:ss
| 'date' // 日期:yyyy-MM-dd
| 'time' // 时间:HH:mm:ss
| 'number' // 数字:千分位
| 'money' // 金额:保留2位小数
| 'percent' // 百分比
| 'enum' // 枚举:配合 enumMap 使用
/**
* 表格列配置
*/
export interface TableColumn {
/** 字段名 */
prop: string
/** 列标题 */
label: string
/** 列宽度 */
width?: string | number
/** 最小宽度 */
minWidth?: string | number
/** 固定位置 */
fixed?: 'left' | 'right'
/** 是否可排序 */
sortable?: boolean | 'custom'
/** 对齐方式 */
align?: 'left' | 'center' | 'right'
/** 数据类型,用于自动格式化(优先级高于 formatter) */
dataType?: DataType
/** 枚举映射(dataType='enum' 时使用) */
enumMap?: Record<string | number, string>
/** 格式化函数(dataType 无法满足时使用) */
formatter?: (row: any, column: any, cellValue: any, index: number) => string
/** 是否使用自定义 slotslot 名为 column-{prop} */
slot?: boolean
/** 是否隐藏 */
hide?: boolean
/** 是否显示提示(show-overflow-tooltip */
tooltip?: boolean
/** 是否可导出 */
exportable?: boolean
/** 是否可编辑 */
editable?: boolean
/** 编辑类型 input/select/switch/number */
editType?: 'input' | 'select' | 'switch' | 'number'
/** 编辑选项(editType='select' 时使用) */
editOptions?: { label: string; value: any }[]
}
/**
* 分页查询结果
*/
export interface PageResult<T = any> {
/** 列表数据 */
list: T[]
/** 总记录数 */
total: number
}
/**
* 分页参数
*/
export interface PageParams {
page: number
size: number
}
/**
* 排序参数
*/
export interface SortParams {
sortField?: string
sortOrder?: 'asc' | 'desc'
}
const props = withDefaults(defineProps<{
/** 列配置 */
columns: TableColumn[]
/** 数据加载函数,接收分页参数,返回 Promise<PageResult> */
loadData: (params: PageParams & Record<string, any>) => Promise<PageResult>
/** 是否立即加载 */
immediate?: boolean
/** 行 key */
rowKey?: string
/** 显示序号列 */
showIndex?: boolean
/** 显示选择列 */
showSelection?: boolean
/** 显示工具栏 */
showToolbar?: boolean
/** 默认分页大小 */
defaultPageSize?: number
/** 分页大小选项 */
pageSizes?: number[]
/** 是否支持导出 */
exportable?: boolean
/** 导出文件名 */
exportFilename?: string
/** 是否支持导入 */
importable?: boolean
/** 导入接口地址 */
importUrl?: string
}>(), {
immediate: true,
rowKey: 'id',
showIndex: true,
showSelection: false,
showToolbar: true,
defaultPageSize: 10,
pageSizes: () => [10, 20, 50, 100],
exportable: false,
exportFilename: '数据导出',
importable: false,
})
const emit = defineEmits<{
/** 刷新后触发 */
(e: 'refresh', data: any[]): void
/** 选择变化 */
(e: 'selection-change', rows: any[]): void
/** 行内保存 */
(e: 'row-save', row: any): void
}>()
// ==================== 导入 ====================
/** 导入弹窗显示状态 */
const importVisible = ref(false)
/** 导入文件 */
const importFile = ref<File | null>(null)
/** 导入加载状态 */
const importLoading = ref(false)
/**
* 处理导入
*/
async function handleImport() {
if (!importFile.value) {
ElMessage.warning('请选择文件')
return
}
importLoading.value = true
try {
const formData = new FormData()
formData.append('file', importFile.value)
const res: any = await request({
url: props.importUrl || '',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
})
if (res.error === 0) {
ElMessage.success(`导入成功,共 ${res.data} 条记录`)
importVisible.value = false
importFile.value = null
load()
} else {
ElMessage.error(res.message || '导入失败')
}
} catch {
// 错误已由请求拦截器统一提示
} finally {
importLoading.value = false
}
}
// 加载状态
const loading = ref(false)
// 数据列表
const tableData = ref<any[]>([])
// 分页参数
const pagination = reactive({
page: 1,
size: props.defaultPageSize,
total: 0,
})
// 查询参数(由外部通过 search slot 填充)
const queryParams = reactive<Record<string, any>>({})
// 排序参数
const sortParams = reactive<SortParams>({
sortField: undefined,
sortOrder: undefined,
})
// 列显示控制
const columnVisibility = reactive<Record<string, boolean>>({})
// 初始化列显示状态
function initColumnVisibility() {
props.columns.forEach(col => {
if (columnVisibility[col.prop] === undefined) {
columnVisibility[col.prop] = !col.hide
}
})
}
initColumnVisibility()
// ==================== 行内编辑 ====================
/** 当前正在编辑的行 ID */
const editingRowId = ref<string | number | null>(null)
/** 编辑中的行数据(深拷贝) */
const editingRowData = ref<any>({})
/**
* 进入编辑模式
*/
function startEdit(row: any) {
editingRowId.value = row[props.rowKey]
editingRowData.value = JSON.parse(JSON.stringify(row))
}
/**
* 保存编辑
*/
function saveEdit(row: any) {
emit('row-save', editingRowData.value)
editingRowId.value = null
editingRowData.value = {}
}
/**
* 取消编辑
*/
function cancelEdit() {
editingRowId.value = null
editingRowData.value = {}
}
// 可见列(考虑用户自定义显示/隐藏)
const visibleColumns = computed(() => {
return props.columns.filter(c => columnVisibility[c.prop] !== false)
})
/**
* 加载数据
*/
async function load() {
loading.value = true
try {
const params: any = {
page: pagination.page,
size: pagination.size,
...queryParams,
}
// 添加排序参数
if (sortParams.sortField && sortParams.sortOrder) {
params.sortField = sortParams.sortField
params.sortOrder = sortParams.sortOrder
}
const res = await props.loadData(params)
tableData.value = res.list || []
pagination.total = res.total || 0
emit('refresh', tableData.value)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
/**
* 搜索(重置到第一页)
*/
function search() {
pagination.page = 1
load()
}
/**
* 重置(清空查询参数并加载)
*/
function reset() {
Object.keys(queryParams).forEach(key => delete queryParams[key])
// 重置排序
sortParams.sortField = undefined
sortParams.sortOrder = undefined
pagination.page = 1
load()
}
/**
* 刷新(保持当前页)
*/
function refresh() {
load()
}
/**
* 分页大小变化
*/
function handleSizeChange(val: number) {
pagination.size = val
pagination.page = 1
load()
}
/**
* 页码变化
*/
function handleCurrentChange(val: number) {
pagination.page = val
load()
}
/**
* 排序变化
*/
function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
if (order) {
sortParams.sortField = prop
sortParams.sortOrder = order === 'ascending' ? 'asc' : 'desc'
} else {
sortParams.sortField = undefined
sortParams.sortOrder = undefined
}
load()
}
/**
* 选择变化
*/
function handleSelectionChange(rows: any[]) {
emit('selection-change', rows)
}
/**
* 设置查询参数(供外部调用)
*/
function setQueryParams(params: Record<string, any>) {
Object.assign(queryParams, params)
}
/**
* 切换列显示/隐藏
*/
function toggleColumn(prop: string, visible: boolean) {
columnVisibility[prop] = visible
}
/**
* 导出 CSV
*/
function exportData() {
if (!tableData.value.length) {
ElMessage.warning('暂无数据可导出')
return
}
// 表头
const headers = visibleColumns.value
.filter(col => col.exportable !== false)
.map(col => col.label)
// 数据行
const rows = tableData.value.map(row => {
return visibleColumns.value
.filter(col => col.exportable !== false)
.map(col => {
const val = row[col.prop]
const formatter = getFormatter(col)
if (formatter) {
return formatter(row, col, val, 0)
}
return val ?? ''
})
})
// 构建 CSV 内容
const csvContent = [
headers.join(','),
...rows.map(row =>
row.map(cell => {
const str = String(cell).replace(/"/g, '""')
return `"${str}"`
}).join(',')
),
].join('\n')
// 下载文件
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${props.exportFilename}_${new Date().toLocaleDateString()}.csv`
link.click()
URL.revokeObjectURL(link.href)
ElMessage.success('导出成功')
}
/**
* 根据 dataType 获取 formatter 函数
*/
function getFormatter(col: TableColumn): ((row: any, column: any, cellValue: any, index: number) => string) | undefined {
// 如果已定义 formatter,优先使用
if (col.formatter) {
return col.formatter
}
// 根据 dataType 自动格式化
switch (col.dataType) {
case 'dateTime':
return (_row: any, _column: any, val: any) => {
if (!val) return '-'
const d = new Date(val)
return isNaN(d.getTime()) ? String(val) : d.toLocaleString()
}
case 'date':
return (_row: any, _column: any, val: any) => {
if (!val) return '-'
const d = new Date(val)
return isNaN(d.getTime()) ? String(val) : d.toLocaleDateString()
}
case 'time':
return (_row: any, _column: any, val: any) => {
if (!val) return '-'
const d = new Date(val)
return isNaN(d.getTime()) ? String(val) : d.toLocaleTimeString()
}
case 'number':
return (_row: any, _column: any, val: any) => {
if (val == null) return '-'
const num = Number(val)
return isNaN(num) ? String(val) : num.toLocaleString()
}
case 'money':
return (_row: any, _column: any, val: any) => {
if (val == null) return '-'
const num = Number(val)
return isNaN(num) ? String(val) : '¥' + num.toFixed(2)
}
case 'percent':
return (_row: any, _column: any, val: any) => {
if (val == null) return '-'
const num = Number(val)
return isNaN(num) ? String(val) : (num * 100).toFixed(2) + '%'
}
case 'enum':
return (_row: any, _column: any, val: any) => {
if (val == null) return '-'
return col.enumMap?.[val] ?? String(val)
}
default:
return undefined
}
}
// 暴露方法
defineExpose({
load,
search,
reset,
refresh,
setQueryParams,
startEdit,
saveEdit,
cancelEdit,
})
onMounted(() => {
if (props.immediate) {
load()
}
})
</script>
<template>
<div class="rui-table">
<!-- 查询区域 -->
<div v-if="$slots.search" class="search-area">
<el-form :model="queryParams" inline>
<slot name="search" :query="queryParams" :search="search" :reset="reset" />
</el-form>
</div>
<!-- 工具栏 -->
<div v-if="showToolbar" class="toolbar">
<div class="toolbar-left">
<slot name="toolbar-left" />
</div>
<div class="toolbar-right">
<slot name="toolbar-right" />
<!-- 列显示控制 -->
<el-dropdown v-if="columns.length > 0" trigger="click">
<el-button :icon="ArrowDown" circle size="small" title="列设置" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="col in columns"
:key="col.prop"
:divided="false"
>
<el-checkbox
:model-value="columnVisibility[col.prop]"
@update:model-value="(val) => toggleColumn(col.prop, val as boolean)"
>
{{ col.label }}
</el-checkbox>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 导入按钮 -->
<el-button
v-if="importable"
type="warning"
circle
size="small"
title="导入"
@click="importVisible = true"
>
<el-icon><Upload /></el-icon>
</el-button>
<!-- 导出按钮 -->
<el-button
v-if="exportable"
type="success"
circle
size="small"
title="导出"
@click="exportData"
>
<el-icon><ArrowDown /></el-icon>
</el-button>
<el-button :icon="RefreshRight" circle size="small" title="刷新" @click="refresh" />
</div>
</div>
<!-- 表格 -->
<el-table
v-loading="loading"
:data="tableData"
:row-key="rowKey"
stripe
border
style="width: 100%"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
>
<!-- 选择列 -->
<el-table-column v-if="showSelection" type="selection" width="50" align="center" />
<!-- 序号列 -->
<el-table-column v-if="showIndex" type="index" label="序号" width="60" align="center" />
<!-- 动态列 -->
<template v-for="col in visibleColumns" :key="col.prop">
<!-- 自定义 slot 列 -->
<el-table-column
v-if="col.slot"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:fixed="col.fixed"
:sortable="col.sortable"
:align="col.align || 'left'"
:show-overflow-tooltip="col.tooltip"
>
<template #default="scope">
<slot :name="`column-${col.prop}`" v-bind="scope" />
</template>
</el-table-column>
<!-- 普通列(支持行内编辑) -->
<el-table-column
v-else
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:fixed="col.fixed"
:sortable="col.sortable"
:align="col.align || 'left'"
:show-overflow-tooltip="col.tooltip"
>
<template #default="scope">
<!-- 编辑模式 -->
<template v-if="col.editable && editingRowId === scope.row[rowKey]">
<el-input
v-if="!col.editType || col.editType === 'input'"
v-model="editingRowData[col.prop]"
size="small"
/>
<el-input-number
v-else-if="col.editType === 'number'"
v-model="editingRowData[col.prop]"
size="small"
style="width: 100%"
/>
<el-select
v-else-if="col.editType === 'select'"
v-model="editingRowData[col.prop]"
size="small"
style="width: 100%"
>
<el-option
v-for="opt in col.editOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-switch
v-else-if="col.editType === 'switch'"
v-model="editingRowData[col.prop]"
size="small"
/>
</template>
<!-- 查看模式 -->
<template v-else>
{{ getFormatter(col)?.(scope.row, col, scope.row[col.prop], scope.$index) ?? scope.row[col.prop] }}
</template>
</template>
</el-table-column>
</template>
<!-- 操作列 -->
<el-table-column label="操作" fixed="right" min-width="120" align="center">
<template #default="scope">
<!-- 行内编辑操作 -->
<template v-if="columns.some(c => c.editable)">
<template v-if="editingRowId === scope.row[rowKey]">
<el-button link type="primary" size="small" @click="saveEdit(scope.row)">
保存
</el-button>
<el-button link size="small" @click="cancelEdit">
取消
</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" @click="startEdit(scope.row)">
编辑
</el-button>
<slot name="action" v-bind="scope" />
</template>
</template>
<template v-else>
<slot name="action" v-bind="scope" />
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-area">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="pageSizes"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 导入弹窗 -->
<el-dialog
v-model="importVisible"
title="导入数据"
width="500px"
class="rui-dialog"
>
<el-upload
drag
action="#"
:auto-upload="false"
:on-change="(file: any) => importFile = file.raw"
accept=".xlsx,.xls"
:limit="1"
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">
拖拽文件到此处或 <em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
仅支持 .xlsx / .xls 格式
</div>
</template>
</el-upload>
<template #footer>
<el-button @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importLoading" @click="handleImport">
确认导入
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.rui-table {
background: var(--el-bg-color-overlay);
border-radius: 8px;
padding: 20px;
}
.search-area {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.toolbar-left {
display: flex;
gap: 8px;
}
.toolbar-right {
display: flex;
gap: 8px;
}
.pagination-area {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
:deep(.el-dropdown-menu__item) {
padding: 0 16px;
}
</style>
-218
View File
@@ -1,218 +0,0 @@
<script setup lang="ts">
import {watch, ref, nextTick} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {useTagsStore} from '@/stores/tags'
import {useAppStore} from '@/stores/app'
import {storeToRefs} from 'pinia'
const route = useRoute()
const router = useRouter()
const tags = useTagsStore()
const app = useAppStore()
const { t } = useI18n()
const {visited} = storeToRefs(tags)
watch(() => route.path, () => tags.addTag(route), {immediate: true})
function handleClose(path: string, index: number) {
tags.removeTag(path)
if (route.path === path) {
const prev = visited.value[index - 1] || visited.value[index]
if (prev) router.push(prev.path)
}
}
function handleCommand(cmd: string, path: string, index: number) {
if (cmd === 'close') handleClose(path, index)
if (cmd === 'refresh') {
// 通过 query 参数触发重新渲染
router.replace({ path, query: { _t: String(Date.now()) } })
}
if (cmd === 'fullscreen') {
if (route.path !== path) router.push(path) // 先切换到目标页面
nextTick(() => app.togglePageFullscreen()) // 等页面切换后再全屏
}
if (cmd === 'closeOther') tags.closeOther(path)
if (cmd === 'closeAll') { tags.closeAll(); router.push('/dashboard') }
hideMenu()
}
const contextMenu = ref({ visible: false, x: 0, y: 0, path: '', index: 0 })
function showContextMenu(e: MouseEvent, path: string, index: number) {
e.preventDefault()
contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, path, index }
}
function hideMenu() {
contextMenu.value.visible = false
}
function scrollTags(dir: number) {
const wrap = document.querySelector('.tags-bar .el-scrollbar__wrap') as HTMLElement
if (wrap) wrap.scrollBy({ left: dir * 200, behavior: 'smooth' })
}
</script>
<template>
<div class="tags-bar">
<span class="tag-arrow" @click="scrollTags(-1)"><el-icon :size="16"><ArrowLeft /></el-icon></span>
<el-scrollbar>
<div class="tags-list">
<div
v-for="(tag, i) in visited" :key="tag.path"
:class="['tag', { active: route.path === tag.path }]"
@click="router.push(tag.path)"
@contextmenu.prevent="showContextMenu($event, tag.path, i)"
>
<div class="tag-wrap">
<span class="tag-text">{{ t(tag.title) || tag.title }}</span>
<span
v-if="tag.path !== '/dashboard'"
class="tag-close"
@click.stop="handleClose(tag.path, i)"
><el-icon :size="12"><Close/></el-icon></span>
</div>
</div>
</div>
</el-scrollbar>
<span class="tag-arrow" @click="scrollTags(1)"><el-icon :size="16"><ArrowRight /></el-icon></span>
<!-- 右键菜单 -->
<teleport to="body">
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<div class="context-item" @click="handleCommand('close', contextMenu.path, contextMenu.index)">{{ t('context.close') }}</div>
<div class="context-item" @click="handleCommand('refresh', contextMenu.path, contextMenu.index)">{{ t('context.refresh') }}</div>
<div class="context-item" @click="handleCommand('fullscreen', contextMenu.path, contextMenu.index)">{{ t('context.fullscreen') }}</div>
<div class="context-divider" />
<div class="context-item" @click="handleCommand('closeOther', contextMenu.path, contextMenu.index)">{{ t('context.closeOther') }}</div>
<div class="context-item" @click="handleCommand('closeAll', contextMenu.path, contextMenu.index)">{{ t('context.closeAll') }}</div>
</div>
<div v-if="contextMenu.visible" class="context-overlay" @click="hideMenu" />
</teleport>
</div>
</template>
<style scoped>
.tags-bar {
background-color: var(--el-bg-color-overlay);
border-top: 1px solid var(--el-border-color-light);
border-bottom: 1px solid var(--el-border-color-light);
position: relative; z-index: 4; display: flex; align-items: center;
padding-top: 22px;
}
html.dark .tags-bar { border-color: #2a2a2a; }
.tags-bar .el-scrollbar__wrap {
overflow-x:auto !important
}
.tags-list {
margin: 0;
display: flex;
align-items: center;
color: var(--el-text-color-regular);
font-size: 12px;
white-space: nowrap;
padding:0 15px;
}
.tag {
display: flex;
align-items: center;
padding: 0 7px;
margin-right: 5px;
border-radius: 2px;
position: relative;
z-index: 0;
cursor: pointer;
justify-content: space-between;
height: 26px;
border-width: 1px 27px 1px;
border-style: solid;
border-color: transparent;
margin: 0 -15px;
}
.tag:hover {
border-color:var(--el-color-primary-light-5);
background-color: var(--el-color-primary-light-5);
color:unset;
}
.active, .tag:hover {
-webkit-mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIHRyYW5zZm9ybT0icm90YXRlKC0wLjEzMzUwNiA1MC4xMTkyIDUwKSIgaWQ9InN2Z18xIiBkPSJtMTAwLjExOTE5LDEwMGMtNTUuMjI4LDAgLTEwMCwtNDQuNzcyIC0xMDAsLTEwMGwwLDEwMGwxMDAsMHoiIG9wYWNpdHk9InVuZGVmaW5lZCIgc3Ryb2tlPSJudWxsIiBmaWxsPSIjRjhFQUU3Ii8+CiAgPHBhdGggZD0ibS0wLjYzNzY2LDcuMzEyMjhjMC4xMTkxOSwwIDAuMjE3MzcsMC4wNTc5NiAwLjQ3Njc2LDAuMTE5MTljMC4yMzIsMC4wNTQ3NyAwLjI3MzI5LDAuMDM0OTEgMC4zNTc1NywwLjExOTE5YzAuMDg0MjgsMC4wODQyOCAwLjM1NzU3LDAgMC40NzY3NiwwbDAuMTE5MTksMGwwLjIzODM4LDAiIGlkPSJzdmdfMiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0ibTI4LjkyMTM0LDY5LjA1MjQ0YzAsMC4xMTkxOSAwLDAuMjM4MzggMCwwLjM1NzU3bDAsMC4xMTkxOWwwLDAuMTE5MTkiIGlkPSJzdmdfMyIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z180IiBoZWlnaHQ9IjAiIHdpZHRoPSIxLjMxMTA4IiB5PSI2LjgzNTUyIiB4PSItMC4wNDE3MSIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z181IiBoZWlnaHQ9IjEuNzg3ODQiIHdpZHRoPSIwLjExOTE5IiB5PSI2OC40NTY1IiB4PSIyOC45MjEzNCIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z182IiBoZWlnaHQ9IjQuODg2NzciIHdpZHRoPSIxOS4wNzAzMiIgeT0iNTEuMjkzMjEiIHg9IjM2LjY2ODY2IiBzdHJva2U9Im51bGwiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+), url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoLTg5Ljc2MjQgNy4zMzAxNCA1NS4xMjUyKSIgc3Ryb2tlPSJudWxsIiBpZD0ic3ZnXzEiIGZpbGw9IiNGOEVBRTciIGQ9Im02Mi41NzQ0OSwxMTcuNTIwODZjLTU1LjIyOCwwIC0xMDAsLTQ0Ljc3MiAtMTAwLC0xMDBsMCwxMDBsMTAwLDB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDxwYXRoIGQ9Im0tMC42Mzc2Niw3LjMxMjI4YzAuMTE5MTksMCAwLjIxNzM3LDAuMDU3OTYgMC40NzY3NiwwLjExOTE5YzAuMjMyLDAuMDU0NzcgMC4yNzMyOSwwLjAzNDkxIDAuMzU3NTcsMC4xMTkxOWMwLjA4NDI4LDAuMDg0MjggMC4zNTc1NywwIDAuNDc2NzYsMGwwLjExOTE5LDBsMC4yMzgzOCwwIiBpZD0ic3ZnXzIiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Im0yOC45MjEzNCw2OS4wNTI0NGMwLDAuMTE5MTkgMCwwLjIzODM4IDAsMC4zNTc1N2wwLDAuMTE5MTlsMCwwLjExOTE5IiBpZD0ic3ZnXzMiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNCIgaGVpZ2h0PSIwIiB3aWR0aD0iMS4zMTEwOCIgeT0iNi44MzU1MiIgeD0iLTAuMDQxNzEiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNSIgaGVpZ2h0PSIxLjc4Nzg0IiB3aWR0aD0iMC4xMTkxOSIgeT0iNjguNDU2NSIgeD0iMjguOTIxMzQiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNiIgaGVpZ2h0PSI0Ljg4Njc3IiB3aWR0aD0iMTkuMDcwMzIiIHk9IjUxLjI5MzIxIiB4PSIzNi42Njg2NiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiA8L2c+Cjwvc3ZnPg==), url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect rx='8' width='100%' height='100%' fill='%23F8EAE7'/></svg>");
-webkit-mask-size: 18px 30px, 20px 30px, calc(100% - 30px) calc(100% + 17px);
-webkit-mask-position: right bottom, left bottom, center top;
-webkit-mask-repeat: no-repeat;
mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIHRyYW5zZm9ybT0icm90YXRlKC0wLjEzMzUwNiA1MC4xMTkyIDUwKSIgaWQ9InN2Z18xIiBkPSJtMTAwLjExOTE5LDEwMGMtNTUuMjI4LDAgLTEwMCwtNDQuNzcyIC0xMDAsLTEwMGwwLDEwMGwxMDAsMHoiIG9wYWNpdHk9InVuZGVmaW5lZCIgc3Ryb2tlPSJudWxsIiBmaWxsPSIjRjhFQUU3Ii8+CiAgPHBhdGggZD0ibS0wLjYzNzY2LDcuMzEyMjhjMC4xMTkxOSwwIDAuMjE3MzcsMC4wNTc5NiAwLjQ3Njc2LDAuMTE5MTljMC4yMzIsMC4wNTQ3NyAwLjI3MzI5LDAuMDM0OTEgMC4zNTc1NywwLjExOTE5YzAuMDg0MjgsMC4wODQyOCAwLjM1NzU3LDAgMC40NzY3NiwwbDAuMTE5MTksMGwwLjIzODM4LDAiIGlkPSJzdmdfMiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0ibTI4LjkyMTM0LDY5LjA1MjQ0YzAsMC4xMTkxOSAwLDAuMjM4MzggMCwwLjM1NzU3bDAsMC4xMTkxOWwwLDAuMTE5MTkiIGlkPSJzdmdfMyIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z180IiBoZWlnaHQ9IjAiIHdpZHRoPSIxLjMxMTA4IiB5PSI2LjgzNTUyIiB4PSItMC4wNDE3MSIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z181IiBoZWlnaHQ9IjEuNzg3ODQiIHdpZHRoPSIwLjExOTE5IiB5PSI2OC40NTY1IiB4PSIyOC45MjEzNCIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z182IiBoZWlnaHQ9IjQuODg2NzciIHdpZHRoPSIxOS4wNzAzMiIgeT0iNTEuMjkzMjEiIHg9IjM2LjY2ODY2IiBzdHJva2U9Im51bGwiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+), url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoLTg5Ljc2MjQgNy4zMzAxNCA1NS4xMjUyKSIgc3Ryb2tlPSJudWxsIiBpZD0ic3ZnXzEiIGZpbGw9IiNGOEVBRTciIGQ9Im02Mi41NzQ0OSwxMTcuNTIwODZjLTU1LjIyOCwwIC0xMDAsLTQ0Ljc3MiAtMTAwLC0xMDBsMCwxMDBsMTAwLDB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDxwYXRoIGQ9Im0tMC42Mzc2Niw3LjMxMjI4YzAuMTE5MTksMCAwLjIxNzM3LDAuMDU3OTYgMC40NzY3NiwwLjExOTE5YzAuMjMyLDAuMDU0NzcgMC4yNzMyOSwwLjAzNDkxIDAuMzU3NTcsMC4xMTkxOWMwLjA4NDI4LDAuMDg0MjggMC4zNTc1NywwIDAuNDc2NzYsMGwwLjExOTE5LDBsMC4yMzgzOCwwIiBpZD0ic3ZnXzIiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Im0yOC45MjEzNCw2OS4wNTI0NGMwLDAuMTE5MTkgMCwwLjIzODM4IDAsMC4zNTc1N2wwLDAuMTE5MTlsMCwwLjExOTE5IiBpZD0ic3ZnXzMiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNCIgaGVpZ2h0PSIwIiB3aWR0aD0iMS4zMTEwOCIgeT0iNi44MzU1MiIgeD0iLTAuMDQxNzEiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNSIgaGVpZ2h0PSIxLjc4Nzg0IiB3aWR0aD0iMC4xMTkxOSIgeT0iNjguNDU2NSIgeD0iMjguOTIxMzQiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNiIgaGVpZ2h0PSI0Ljg4Njc3IiB3aWR0aD0iMTkuMDcwMzIiIHk9IjUxLjI5MzIxIiB4PSIzNi42Njg2NiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiA8L2c+Cjwvc3ZnPg==), url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect rx='8' width='100%' height='100%' fill='%23F8EAE7'/></svg>");
mask-size: 18px 30px, 20px 30px, calc(100% - 30px) calc(100% + 17px);
mask-position: right bottom, left bottom, center top;
mask-repeat: no-repeat;
}
.tag-close {
border-radius: 100%;
width: 14px; height: 14px; text-align: center; line-height: 14px;
display: flex; align-items: center; justify-content: center;
opacity: 0.6; transition: all 0.15s;
}
.tag.active .tag-close { color: rgba(255,255,255,0.7); }
.tag-close:hover { opacity: 1; background-color: var(--el-color-primary-light-3); color: #fff; }
.tag-wrap{
display: flex;
align-items: center;
justify-items: center ;
}
.tag:hover {
color:unset;
z-index: 2;
}
.tags-list .active {
color: var(--el-color-white);
border-color: var(--el-color-primary);
z-index: 1;
background-color: var(--el-color-primary);
}
.tag-arrow {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 4px; cursor: pointer;
color: #999; flex-shrink: 0; transition: all 0.15s;
}
.tag-arrow:hover { color: #333; background: rgba(0,0,0,0.06); }
html.dark .tag-arrow:hover { color: #ccc; background: rgba(255,255,255,0.06); }
/* 右键菜单 */
.context-menu {
position: fixed; z-index: 9999;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
padding: 4px 0; min-width: 100px;
}
.context-item {
padding: 8px 16px; font-size: 13px; cursor: pointer; color: #333;
transition: background 0.15s;
}
.context-item:hover { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
.context-divider { height: 1px; margin: 4px 8px; background: var(--el-border-color-light); }
.context-overlay { position: fixed; inset: 0; z-index: 9998; }
html.dark .context-item { color: #ccc; }
html.dark .context-item:hover { background: rgba(64,158,255,0.1); }
</style>
-68
View File
@@ -1,68 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
import { useI18n } from 'vue-i18n'
const visible = defineModel<boolean>()
const app = useAppStore()
const { t, locale } = useI18n()
const colors = ['#1677ff', '#409eff', '#52c41a', '#fa8c16', '#f5222d', '#722ed1', '#13c2c2', '#eb2f96']
function onLangChange(v: string) {
locale.value = v // 立即响应,无需刷新
app.setLang(v)
}
function onLayoutChange(v: string) {
app.setLayout(v)
}
</script>
<template>
<el-drawer v-model="visible" :title="t('theme.title')" size="280px">
<!-- 暗黑模式 -->
<div class="mb-5">
<p class="text-sm mb-2 text-gray-500">{{ t('theme.dark') }}</p>
<el-switch :model-value="app.dark" @change="app.toggleDark()" :active-text="app.dark ? '🌙' : ''" />
</div>
<!-- 语言 -->
<div class="mb-5">
<p class="text-sm mb-2 text-gray-500">语言 / Language</p>
<el-radio-group :model-value="locale" @change="onLangChange">
<el-radio-button value="zh-CN">中文</el-radio-button>
<el-radio-button value="en-US">English</el-radio-button>
</el-radio-group>
</div>
<!-- 布局 -->
<div class="mb-5">
<p class="text-sm mb-2 text-gray-500">{{ t('theme.layout') }}</p>
<div class="grid grid-cols-2 gap-2">
<div
v-for="l in (['side', 'top', 'mix', 'double'] as const)"
:key="l"
:class="['p-2 rounded text-center text-xs cursor-pointer border-2', app.layout === l ? 'border-blue-500 bg-blue-50' : 'border-gray-200']"
@click="onLayoutChange(l)"
>
{{ t(`theme.layout${l.charAt(0).toUpperCase() + l.slice(1)}` as any) }}
</div>
</div>
</div>
<!-- 主题色 -->
<div>
<p class="text-sm mb-2 text-gray-500">{{ t('theme.color') }}</p>
<div class="flex gap-2 flex-wrap">
<div
v-for="c in colors"
:key="c"
:style="{ background: c }"
:class="['w-6 h-6 rounded-full cursor-pointer', app.primaryColor === c ? 'ring-2 ring-offset-2 ring-blue-500' : '']"
@click="app.setPrimaryColor(c)"
/>
</div>
</div>
</el-drawer>
</template>
-147
View File
@@ -1,147 +0,0 @@
import { ref, computed } from 'vue'
import { menuService } from '@/service/system/menuService'
/**
* 图标名称映射
* - 如果图标以 "tabler:" 开头,直接透传(使用 Iconify 渲染)
* - 否则映射为 Element Plus 图标组件名
*/
function getIconName(icon?: string): string {
if (!icon) return 'FolderOpened'
// Tabler 图标直接透传
if (icon.startsWith('tabler:')) return icon
// Element Plus 图标映射
const iconMap: Record<string, string> = {
'setting': 'Setting',
'user': 'UserFilled',
'monitor': 'Monitor',
'document': 'Document',
'edit': 'Edit',
'present': 'Present',
'tools': 'Tools',
'home': 'HomeFilled',
}
const normalized = icon.charAt(0).toUpperCase() + icon.slice(1).toLowerCase()
return iconMap[normalized.toLowerCase()] || normalized || 'FolderOpened'
}
/**
* 将后端菜单转换为前端格式
*/
function transformMenu(menu: any): any {
const item: any = {
id: menu.id,
title: menu.menuName,
icon: getIconName(menu.icon),
path: menu.path,
}
if (menu.children && menu.children.length > 0) {
const validChildren = menu.children.filter((child: any) => child.menuType !== 3)
if (validChildren.length > 0) {
item.children = validChildren.map(transformMenu)
}
}
return item
}
/**
* 递归获取菜单下的第一个可跳转路径
*/
function getFirstPath(menu: any): string | undefined {
if (menu.path) return menu.path
if (menu.children && menu.children.length > 0) {
for (const child of menu.children) {
const path = getFirstPath(child)
if (path) return path
}
}
return undefined
}
/**
* 根据路径在菜单树中查找对应的面包屑路径
*/
function findBreadcrumbPath(menus: any[], targetPath: string): any[] {
for (const menu of menus) {
if (menu.path === targetPath) {
return [{ id: menu.id, title: menu.menuName, path: menu.path }]
}
if (menu.children && menu.children.length > 0) {
const childPath = findBreadcrumbPath(menu.children, targetPath)
if (childPath.length > 0) {
return [{ id: menu.id, title: menu.menuName, path: menu.path }, ...childPath]
}
}
}
return []
}
/**
* 菜单管理 Composable
*/
export function useMenu() {
const rawMenus = ref<any[]>([])
const loading = ref(false)
const menuItems = computed(() => {
const filtered = rawMenus.value.filter((menu: any) => menu.menuType !== 3)
return filtered.map(transformMenu)
})
/**
* 一级菜单列表(用于 MixLayout/DoubleLayout 的顶栏/图标栏)
*/
const topMenus = computed(() => {
return menuItems.value.map((item: any) => ({
id: item.id,
key: String(item.id),
title: item.title,
icon: item.icon,
path: item.path || getFirstPath(item),
children: item.children,
}))
})
/**
* 根据一级菜单 ID 获取对应的二级菜单列表
*/
function getSubMenus(topMenuId: string | number): any[] {
const topMenu = rawMenus.value.find((m: any) => String(m.id) === String(topMenuId))
if (!topMenu || !topMenu.children) return []
return topMenu.children
.filter((child: any) => child.menuType !== 3)
.map(transformMenu)
}
/**
* 根据当前路径生成面包屑数据
*/
function getBreadcrumbs(currentPath: string): any[] {
const paths = findBreadcrumbPath(rawMenus.value, currentPath)
return paths.map(p => ({ id: p.id, title: p.title, path: p.path }))
}
async function loadMenus(platform?: string) {
loading.value = true
try {
rawMenus.value = await menuService.userTree(platform)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
return {
menuItems,
topMenus,
rawMenus,
loading,
loadMenus,
getSubMenus,
getBreadcrumbs,
getFirstPath,
}
}
-18
View File
@@ -1,18 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
import SideLayout from './SideLayout.vue'
import TopNavLayout from './TopNavLayout.vue'
import MixLayout from './MixLayout.vue'
import DoubleLayout from './DoubleLayout.vue'
import ThemeDrawer from '@/components/ThemeDrawer.vue'
const app = useAppStore()
const Component = computed(() => {
return { side: SideLayout, top: TopNavLayout, mix: MixLayout, double: DoubleLayout }[app.layout] || SideLayout
})
</script>
<template>
<component :is="Component" :key="app.layout" />
<ThemeDrawer v-model="app.themeVisible" />
</template>
-215
View File
@@ -1,215 +0,0 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useMenu } from '@/composables/useMenu'
import TagsBar from '@/components/TagsBar.vue'
import RuiIcon from '@/components/RuiIcon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const user = useUserStore()
const { topMenus, loadMenus, getSubMenus, getBreadcrumbs, getFirstPath } = useMenu()
const activeTop = ref('')
const subCollapsed = ref(false)
const subMenus = computed(() => {
if (!activeTop.value) return []
return getSubMenus(activeTop.value)
})
/**
* 面包屑数据
*/
const breadcrumbs = computed(() => {
const crumbs = getBreadcrumbs(route.path)
if (crumbs.length === 0) {
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
}
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
})
function onTopClick(item: any) {
activeTop.value = item.key
if (item.path) { router.push(item.path); return }
const first = getFirstPath(item)
if (first) router.push(first)
}
onMounted(() => {
loadMenus('admin').then(() => {
if (topMenus.value.length > 0 && !activeTop.value) {
activeTop.value = topMenus.value[0].key
}
})
})
</script>
<template>
<el-container class="h-screen">
<!-- 图标栏 -->
<div class="icon-col">
<el-icon :size="22" class="my-3 icon-logo"><Setting /></el-icon>
<el-tooltip v-for="item in topMenus" :key="item.key" :content="item.title" placement="right">
<div :class="['icon-btn', { active: activeTop === item.key }]" @click="onTopClick(item)">
<RuiIcon :icon="item.icon" :size="20" />
</div>
</el-tooltip>
</div>
<!-- 二级菜单可收起 -->
<div v-if="subMenus.length" class="sub-col" :class="{ 'sub-collapsed': subCollapsed }">
<div class="sub-col-header">
<span v-show="!subCollapsed" class="sub-title">{{ topMenus.find(m => m.key === activeTop)?.title }}</span>
<el-icon :size="16" class="cursor-pointer sub-toggle" @click="subCollapsed = !subCollapsed">
<Fold v-if="!subCollapsed" /><Expand v-else />
</el-icon>
</div>
<el-scrollbar>
<el-menu v-if="!subCollapsed" :default-active="route.path" router class="sub-menu" :key="activeTop">
<template v-for="s in subMenus" :key="s.id">
<el-sub-menu v-if="s.children?.length" :index="String(s.id)">
<template #title>
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
<span>{{ s.title }}</span>
</template>
<el-menu-item v-for="c in s.children" :key="c.id" :index="c.path">
<RuiIcon v-if="c.icon" :icon="c.icon" :size="16" class="menu-icon" />
<span>{{ c.title }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="s.path">
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
<span>{{ s.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</div>
<!-- 内容区 -->
<el-container class="flex-1">
<el-header class="top-bar">
<div class="top-bar-inner">
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
<!-- 右侧工具栏 -->
<div class="flex items-center gap-3 ml-auto">
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
<Moon v-if="!app.dark" /><Sunny v-else />
</el-icon>
</el-tooltip>
<el-tooltip content="主题配置" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
</el-tooltip>
<el-dropdown>
<el-badge :value="3" :offset="[-2, 2]">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown>
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
<span class="text-sm">{{ user.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>{{ t('common.personal') }}
</el-dropdown-item>
<el-dropdown-item divided @click="app.logout()">
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<TagsBar />
</el-header>
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style>
/* 图标栏 */
.icon-col {
width: 56px; background: #001529;
display: flex; flex-direction: column; align-items: center;
padding-top: 4px; flex-shrink: 0;
}
.icon-logo { color: var(--el-color-primary); }
.icon-btn {
width: 40px; height: 40px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: #ffffff73; cursor: pointer; transition: all 0.2s;
margin-bottom: 2px;
}
.icon-btn:hover { color: #fff; background: rgba(255,255,255,0.08); }
.icon-btn.active { color: #fff; background: rgba(255,255,255,0.15); }
/* 二级菜单栏 */
.sub-col {
width: 200px; background: #fff;
border-right: 1px solid #eee;
flex-shrink: 0; transition: width 0.2s;
overflow: hidden; display: flex; flex-direction: column;
}
.sub-col.sub-collapsed { width: 48px; }
.sub-col-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; flex-shrink: 0;
}
.sub-col.sub-collapsed .sub-col-header { justify-content: center; padding: 12px 0; }
.sub-toggle { flex-shrink: 0; color: #999; }
.sub-toggle:hover { color: #333; }
.sub-title { font-size: 12px; font-weight: 600; color: #999; text-transform: uppercase; letter-spacing: 0.5px; }
/* 二级菜单样式 */
.sub-menu { border-right: none !important; }
.sub-menu .el-menu-item.is-active {
background: rgba(0,0,0,0.04);
border-right: 3px solid var(--el-color-primary);
color: var(--el-color-primary); font-weight: 500;
}
.menu-icon { margin-right: 5px; }
/* 顶部栏 */
.top-bar { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.top-bar-inner {
display: flex; align-items: center; justify-content: space-between;
height: 52px; background: #fff; padding: 0 20px;
}
/* 主内容区 */
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; }
/* 暗色模式适配 */
html.dark .sub-col { background: #1d1d1d; border-color: #333; }
html.dark .sub-toggle:hover { color: #ccc; }
html.dark .top-bar-inner { background: #1d1d1d; }
html.dark .main { background: #111; }
html.dark .sub-menu .el-menu-item.is-active { background: rgba(255,255,255,0.06); }
html.dark .el-breadcrumb__inner { color: #ccc; }
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
html.dark .el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
</style>
-202
View File
@@ -1,202 +0,0 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useMenu } from '@/composables/useMenu'
import TagsBar from '@/components/TagsBar.vue'
import RuiIcon from '@/components/RuiIcon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const user = useUserStore()
const { topMenus, loadMenus, getSubMenus, getBreadcrumbs, getFirstPath } = useMenu()
const activeTop = ref('')
const sideMenus = computed(() => {
if (!activeTop.value) return []
return getSubMenus(activeTop.value)
})
/**
* 面包屑数据
*/
const breadcrumbs = computed(() => {
const crumbs = getBreadcrumbs(route.path)
if (crumbs.length === 0) {
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
}
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
})
function onTopChange(key: string) {
activeTop.value = key
const subItems = getSubMenus(key)
if (subItems.length > 0) {
const firstPath = getFirstPath(subItems[0])
if (firstPath) router.push(firstPath)
} else {
const topItem = topMenus.value.find((m: any) => m.key === key)
if (topItem?.path) router.push(topItem.path)
}
}
onMounted(() => {
loadMenus('admin').then(() => {
if (topMenus.value.length > 0 && !activeTop.value) {
activeTop.value = topMenus.value[0].key
}
})
})
</script>
<template>
<el-container class="h-screen">
<div class="flex flex-col w-full">
<!-- 顶部一级菜单 + 工具栏 -->
<el-header class="top-header">
<div class="top-header-nav">
<div class="flex items-center gap-6 h-full">
<!-- Logo -->
<span class="text-lg font-bold whitespace-nowrap" :style="{ color: app.primaryColor }">{{ t('app.title') }}</span>
<!-- 一级菜单 -->
<div class="flex gap-0 flex-1">
<div
v-for="item in topMenus"
:key="item.key"
:class="[
'px-3 py-2 text-sm cursor-pointer rounded transition-colors flex items-center gap-1',
activeTop === item.key ? 'text-white' : 'hover:text-blue-500'
]"
:style="activeTop === item.key ? { background: app.primaryColor } : {}"
@click="onTopChange(item.key)"
>
<RuiIcon v-if="item.icon" :icon="item.icon" :size="14" />
<span>{{ item.title }}</span>
</div>
</div>
<!-- 右侧工具栏 -->
<div class="flex items-center gap-3">
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
<Moon v-if="!app.dark" /><Sunny v-else />
</el-icon>
</el-tooltip>
<el-tooltip content="主题配置" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
</el-tooltip>
<el-dropdown>
<el-badge :value="3" :offset="[-2, 2]">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown>
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
<span class="text-sm">{{ user.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>{{ t('common.personal') }}
</el-dropdown-item>
<el-dropdown-item divided @click="app.logout()">
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- 面包屑 + 标签栏 -->
<div class="top-header-bottom">
<div class="breadcrumb-wrap">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<TagsBar />
</div>
</el-header>
<!-- 二级侧边 + 内容 -->
<div class="flex flex-1 overflow-hidden">
<div v-if="sideMenus.length" class="side-sub">
<el-scrollbar>
<el-menu :default-active="route.path" router class="sub-menu">
<template v-for="s in sideMenus" :key="s.id">
<el-sub-menu v-if="s.children?.length" :index="String(s.id)">
<template #title>
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
<span>{{ s.title }}</span>
</template>
<el-menu-item v-for="c in s.children" :key="c.id" :index="c.path">
<RuiIcon v-if="c.icon" :icon="c.icon" :size="16" class="menu-icon" />
<span>{{ c.title }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="s.path">
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
<span>{{ s.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</div>
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
<router-view />
</el-main>
</div>
</div>
</el-container>
</template>
<style scoped>
.top-header { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.top-header-nav { height: 52px; background: #fff; padding: 0 20px; }
html.dark .top-header-nav { background: #1d1d1d; }
/* 面包屑区域 */
.top-header-bottom { background: #fff; border-top: 1px solid var(--el-border-color-light); }
html.dark .top-header-bottom { background: #1d1d1d; border-color: #2a2a2a; }
.breadcrumb-wrap {
display: flex; align-items: center;
height: 28px; padding: 0 20px;
font-size: 13px;
}
.breadcrumb-wrap .el-breadcrumb { line-height: 1; }
/* 二级侧边栏 */
.side-sub { width: 180px; background: #fff; border-right: 1px solid #eee; flex-shrink: 0; }
.sub-menu { border-right: none !important; }
.sub-menu .el-menu-item.is-active {
background: rgba(0,0,0,0.04);
border-right: 3px solid var(--el-color-primary);
color: var(--el-color-primary); font-weight: 500;
}
.menu-icon { margin-right: 5px; }
/* 主内容区 */
.main { background: #f5f6fa; flex: 1; padding: 20px; overflow: auto; }
/* 暗色模式适配 */
html.dark .side-sub { background: #1d1d1d; border-color: #333; }
html.dark .main { background: #111; }
html.dark .sub-menu .el-menu-item.is-active { background: rgba(255,255,255,0.06); }
html.dark .el-breadcrumb__inner { color: #ccc; }
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
html.dark .el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
</style>
-214
View File
@@ -1,214 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useMenu } from '@/composables/useMenu'
import TagsBar from '@/components/TagsBar.vue'
import RuiIcon from '@/components/RuiIcon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const user = useUserStore()
const { menuItems, loadMenus, getBreadcrumbs } = useMenu()
const isCollapse = ref(false)
/**
* 加载菜单
*/
async function loadMenuData() {
try {
await loadMenus('admin')
} catch {
// 错误已由请求拦截器统一提示
}
}
/**
* 面包屑数据
*/
const breadcrumbs = computed(() => {
const crumbs = getBreadcrumbs(route.path)
if (crumbs.length === 0) {
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
}
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
})
onMounted(() => {
loadMenuData()
})
</script>
<template>
<el-container class="h-screen">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="side-bar">
<div class="side-logo" :class="{ collapsed: isCollapse }">
<el-icon :size="22"><Setting /></el-icon>
<span v-show="!isCollapse" class="side-logo-text">{{ t('app.title') }}</span>
</div>
<el-scrollbar>
<el-menu :default-active="route.path" :collapse="isCollapse" router class="side-menu" :collapse-transition="false">
<template v-for="item in menuItems" :key="item.id">
<!-- 有子菜单 -->
<el-sub-menu v-if="item.children?.length" :index="String(item.id)">
<template #title>
<el-tooltip v-if="isCollapse" :content="item.title" placement="right">
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
</el-tooltip>
<RuiIcon v-else-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
<span>{{ item.title }}</span>
</template>
<template v-for="child in item.children" :key="child.id">
<!-- 子菜单还有子菜单三级菜单 -->
<el-sub-menu v-if="child.children?.length" :index="String(child.id)">
<template #title>
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
<span>{{ child.title }}</span>
</template>
<el-menu-item v-for="sub in child.children" :key="sub.id" :index="sub.path">
<RuiIcon v-if="sub.icon" :icon="sub.icon" :size="16" class="menu-icon" />
<span>{{ sub.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 二级菜单 -->
<el-menu-item v-else :index="child.path">
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
<span>{{ child.title }}</span>
</el-menu-item>
</template>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="item.path">
<el-tooltip v-if="isCollapse" :content="item.title" placement="right">
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
</el-tooltip>
<RuiIcon v-else-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
<span>{{ item.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</el-aside>
<!-- 主内容区 -->
<el-container class="flex-1">
<!-- 顶部栏 -->
<el-header class="top-bar">
<div class="top-bar-inner">
<div class="flex items-center gap-3">
<el-icon :size="20" class="cursor-pointer hover:text-blue-500 transition-colors" @click="isCollapse = !isCollapse">
<Fold v-if="!isCollapse" /><Expand v-else />
</el-icon>
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="flex items-center gap-4">
<!-- 暗黑模式切换 -->
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
<Moon v-if="!app.dark" /><Sunny v-else />
</el-icon>
</el-tooltip>
<!-- 主题配置 -->
<el-tooltip content="主题配置" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
</el-tooltip>
<!-- 通知 -->
<el-dropdown>
<el-badge :value="3" :offset="[-2, 2]">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 用户信息 -->
<el-dropdown>
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
<span class="text-sm">{{ user.username }}</span>
<el-icon :size="14"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>{{ t('common.personal') }}
</el-dropdown-item>
<el-dropdown-item divided @click="app.logout()">
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<TagsBar />
</el-header>
<!-- 主内容 -->
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style>
/* 侧边栏暗色调 — 不受亮/暗模式影响 */
.side-bar {
background: #001529 !important;
--el-menu-bg-color: #001529;
--el-menu-text-color: #ffffffb3;
--el-menu-hover-bg-color: #ffffff0d;
--el-menu-active-color: #fff;
--el-menu-border-color: transparent;
--el-sub-menu-title-font-size: 14px;
transition: width 0.3s;
}
.side-logo {
display: flex; align-items: center; gap: 10px; padding: 0 18px; height: 56px;
border-bottom: 1px solid rgba(255,255,255,0.06); color: #fff;
transition: all 0.3s;
}
.side-logo.collapsed { justify-content: center; padding: 0; }
.side-logo-text { font-size: 16px; font-weight: 700; white-space: nowrap; }
.side-menu { border-right: none !important; }
.menu-icon { margin-right: 5px; }
/* 当前激活菜单项加背景色 */
.side-menu .el-menu-item.is-active { background: rgba(255,255,255,0.08) !important; }
.side-menu .el-sub-menu.is-active > .el-sub-menu__title { background: rgba(255,255,255,0.06); }
/* 顶部栏 */
.top-bar {
padding: 0; height: auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.top-bar-inner {
display: flex; align-items: center; justify-content: space-between;
height: 52px; background: #fff; padding: 0 20px;
transition: background 0.3s;
}
html.dark .top-bar-inner { background: #1d1d1d; }
/* 主内容区 */
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; transition: background 0.3s; }
html.dark .main { background: #111; }
/* 面包屑样式优化 */
.el-breadcrumb { font-size: 13px; }
html.dark .el-breadcrumb__inner { color: #ccc; }
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
/* 下拉菜单图标对齐 */
.el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
</style>
-169
View File
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useMenu } from '@/composables/useMenu'
import TagsBar from '@/components/TagsBar.vue'
import RuiIcon from '@/components/RuiIcon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const user = useUserStore()
const { menuItems, loadMenus, getBreadcrumbs } = useMenu()
onMounted(() => {
loadMenus('admin')
})
/**
* 面包屑数据
*/
const breadcrumbs = computed(() => {
const crumbs = getBreadcrumbs(route.path)
if (crumbs.length === 0) {
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
}
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
})
</script>
<template>
<el-container class="h-screen" :class="{ dark: app.dark }">
<!-- 顶部导航 -->
<el-header class="top-header">
<div class="top-header-nav">
<div class="flex items-center gap-6 h-full">
<!-- Logo -->
<span class="text-lg font-bold whitespace-nowrap" :style="{ color: app.primaryColor }">{{ t('app.title') }}</span>
<!-- 水平菜单 -->
<el-menu mode="horizontal" :default-active="route.path" router class="top-menu">
<template v-for="item in menuItems" :key="item.id">
<!-- 有子菜单 -->
<el-sub-menu v-if="item.children?.length" :index="String(item.id)">
<template #title>
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
<span>{{ item.title }}</span>
</template>
<template v-for="child in item.children" :key="child.id">
<!-- 三级菜单 -->
<el-sub-menu v-if="child.children?.length" :index="String(child.id)">
<template #title>
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
<span>{{ child.title }}</span>
</template>
<el-menu-item v-for="sub in child.children" :key="sub.id" :index="sub.path">{{ sub.title }}</el-menu-item>
</el-sub-menu>
<!-- 二级菜单 -->
<el-menu-item v-else :index="child.path">
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
<span>{{ child.title }}</span>
</el-menu-item>
</template>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="item.path">
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
<span>{{ item.title }}</span>
</el-menu-item>
</template>
</el-menu>
<!-- 右侧工具栏 -->
<div class="flex items-center gap-3 ml-auto">
<!-- 暗黑模式 -->
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
<Moon v-if="!app.dark" /><Sunny v-else />
</el-icon>
</el-tooltip>
<!-- 主题配置 -->
<el-tooltip content="主题配置" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
</el-tooltip>
<!-- 通知 -->
<el-dropdown>
<el-badge :value="3" :offset="[-2, 2]">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 用户 -->
<el-dropdown>
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
<span class="text-sm">{{ user.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>{{ t('common.personal') }}
</el-dropdown-item>
<el-dropdown-item divided @click="app.logout()">
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- 面包屑 + 标签栏 -->
<div class="top-header-bottom">
<div class="breadcrumb-wrap">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<TagsBar />
</div>
</el-header>
<!-- 主内容 -->
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
<router-view />
</el-main>
</el-container>
</template>
<style scoped>
.top-header { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.top-header-nav { height: 52px; background: #fff; padding: 0 20px; display: flex; align-items: center; }
html.dark .top-header-nav { background: #1d1d1d; }
/* 面包屑区域 */
.top-header-bottom { background: #fff; border-top: 1px solid var(--el-border-color-light); }
html.dark .top-header-bottom { background: #1d1d1d; border-color: #2a2a2a; }
.breadcrumb-wrap {
display: flex; align-items: center;
height: 28px; padding: 0 20px;
font-size: 13px;
}
.breadcrumb-wrap .el-breadcrumb { line-height: 1; }
.top-menu { border-bottom: none; height: 52px; flex: 1; }
.menu-icon { margin-right: 5px; }
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; transition: background 0.3s; }
html.dark .main { background: #111; }
/* 暗色模式下的菜单适配 */
html.dark .top-menu {
--el-menu-bg-color: #1d1d1d;
--el-menu-text-color: #ccc;
--el-menu-hover-text-color: var(--el-color-primary);
--el-menu-active-color: var(--el-color-primary);
}
html.dark .el-menu--popup {
--el-menu-bg-color: #2a2a2a;
--el-menu-text-color: #ccc;
}
</style>
-107
View File
@@ -1,107 +0,0 @@
export default {
app: {
title: 'Rui Platform',
titleShort: 'Rui',
},
menu: {
home: 'Dashboard',
user: 'User',
userInfo: 'User Info',
level: 'Level',
levelList: 'Level List',
levelLog: 'Level Log',
address: 'Address',
account: 'Account',
system: 'System',
systemAuth: 'Auth',
systemOrg: 'Organization',
systemMenu: 'Menu',
systemRole: 'Role',
systemDept: 'Department',
systemDict: 'Dictionary',
systemConfig: 'Config',
systemLog: 'Log',
order: 'Order',
orderList: 'Order List',
orderRefund: 'Refund',
cms: 'Content',
cmsArticle: 'Article',
cmsCategory: 'Category',
cmsBanner: 'Banner',
marketing: 'Marketing',
marketingCoupon: 'Coupon',
marketingActivity: 'Activity',
demo: 'Demo',
demoIcons: 'Icons',
demoList: 'List',
settings: 'Settings',
},
common: {
search: 'Search',
reset: 'Reset',
add: 'Add',
edit: 'Edit',
delete: 'Delete',
confirm: 'Confirm',
cancel: 'Cancel',
save: 'Save',
enable: 'Enable',
disable: 'Disable',
status: 'Status',
operation: 'Operation',
remark: 'Remark',
personal: 'Profile',
logout: 'Logout',
fullscreen: 'Fullscreen',
breadcrumb: { home: 'Home' },
},
theme: {
title: 'Theme',
dark: 'Dark',
light: 'Light',
layout: 'Layout',
layoutSide: 'Sidebar',
layoutTop: 'Top Menu',
layoutMix: 'Mixed',
layoutDouble: 'Double',
color: 'Primary Color',
fontSize: 'Font Size',
},
dashboard: {
title: 'Overview',
users: 'Total Users',
today: 'New Today',
active: 'Active Users',
orders: 'Total Orders',
recent: 'Recent Activity',
vsLastWeek: 'vs last week',
action: { register: 'registered', login: 'logged in', order: 'purchased', comment: 'commented' },
},
userInfo: {
title: 'User Info',
username: 'Username',
nickname: 'Nickname',
phone: 'Phone',
email: 'Email',
add: 'Add User',
edit: 'Edit User',
deleteConfirm: 'Delete user "{name}"?',
},
userLevel: {
title: 'Level Management',
name: 'Level Name',
code: 'Code',
minScore: 'Min Score',
maxScore: 'Max Score',
add: 'Add Level',
edit: 'Edit Level',
deleteConfirm: 'Delete "{name}"?',
},
context: {
close: 'Close',
refresh: 'Refresh',
fullscreen: 'Fullscreen',
closeOther: 'Close Others',
closeAll: 'Close All',
},
}
-12
View File
@@ -1,12 +0,0 @@
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN'
import enUS from './en-US'
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('lang') || 'zh-CN',
fallbackLocale: 'zh-CN',
messages: { 'zh-CN': zhCN, 'en-US': enUS },
})
export default i18n
-121
View File
@@ -1,121 +0,0 @@
export default {
app: {
title: '睿核通用平台',
titleShort: '睿核',
},
menu: {
home: '首页',
user: '用户管理',
userInfo: '用户信息',
userDetail: '用户详情',
level: '等级管理',
levelList: '等级列表',
levelLog: '等级日志',
address: '收货地址',
account: '账户流水',
system: '系统管理',
systemAuth: '权限管理',
systemOrg: '组织架构',
systemMenu: '菜单管理',
systemRole: '角色管理',
systemDept: '部门管理',
systemDict: '字典管理',
systemConfig: '参数配置',
systemLog: '操作日志',
systemLoginLog: '登录日志',
systemTenant: '租户管理',
systemOAuth2Client: 'OAuth2客户端',
order: '订单管理',
orderList: '订单列表',
orderRefund: '退款记录',
cms: '内容管理',
cmsArticle: '文章管理',
cmsCategory: '分类管理',
cmsBanner: '轮播图',
marketing: '营销中心',
marketingCoupon: '优惠券',
marketingActivity: '活动管理',
demo: '演示中心',
demoIcons: '图标演示',
demoList: '列表演示',
settings: '系统设置',
},
common: {
search: '查询',
reset: '重置',
add: '新增',
edit: '编辑',
delete: '删除',
confirm: '确定',
cancel: '取消',
save: '保存',
enable: '启用',
disable: '禁用',
status: '状态',
operation: '操作',
remark: '备注',
personal: '个人中心',
logout: '退出登录',
fullscreen: '全屏',
all: '全部',
query: '查询',
batchDelete: '批量删除',
success: '成功',
failed: '失败',
breadcrumb: { home: '首页' },
},
theme: {
title: '主题配置',
dark: '暗黑模式',
light: '明亮模式',
layout: '布局风格',
layoutSide: '侧边栏',
layoutTop: '顶栏',
layoutMix: '混合',
layoutDouble: '双栏',
color: '主题色',
fontSize: '字号',
},
dashboard: {
title: '数据概览',
users: '用户总数',
today: '今日新增',
active: '活跃用户',
orders: '订单总数',
recent: '最近动态',
vsLastWeek: '较上周',
action: { register: '注册', login: '登录', order: '购买', comment: '评论' },
},
userInfo: {
title: '用户信息',
username: '用户名',
nickname: '昵称',
phone: '手机号',
email: '邮箱',
status: '状态',
userType: '用户类型',
createdAt: '创建时间',
enabled: '启用',
disabled: '禁用',
add: '新增用户',
edit: '编辑用户',
deleteConfirm: '确定删除用户「{name}」?',
},
userLevel: {
title: '等级管理',
name: '等级名称',
code: '编码',
minScore: '最低分值',
maxScore: '最高分值',
add: '新增等级',
edit: '编辑等级',
deleteConfirm: '确定删除「{name}」?',
},
context: {
close: '关闭当前',
refresh: '刷新当前',
fullscreen: '全屏当前页',
closeOther: '关闭其它',
closeAll: '关闭所有',
},
}
-19
View File
@@ -1,19 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './locales'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'uno.css'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue))
app.component(key, component)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.mount('#app')
-36
View File
@@ -1,36 +0,0 @@
import type { RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
/**
* 全局路由守卫
*/
export function setupRouterGuards(router: any) {
// 白名单路由(无需登录)
const whiteList = ['/login']
router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
const authStore = useAuthStore()
// 白名单直接放行
if (whiteList.includes(to.path)) {
// 已登录用户访问登录页,重定向到首页
if (authStore.isLoggedIn && to.path === '/login') {
next('/')
return
}
next()
return
}
// 需要登录的页面
if (!authStore.isLoggedIn) {
next({
path: '/login',
query: { redirect: to.fullPath },
})
return
}
next()
})
}
-10
View File
@@ -1,10 +0,0 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import routes from 'virtual:generated-routes'
import { setupRouterGuards } from './guards'
const router = createRouter({ history: createWebHashHistory(), routes })
// 设置路由守卫
setupRouterGuards(router)
export default router
-19
View File
@@ -1,19 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
cashierStore: 'menu.cashierStore',
cashierRoom: 'menu.cashierRoom',
cashierPricing: 'menu.cashierPricing',
cashierOrder: 'menu.cashierOrder',
cashierProduct: 'menu.cashierProduct',
cashierReport: 'menu.cashierReport',
}
export const cashierRoutes: RouteRecordRaw[] = [
{ path: 'cashier/store', name: 'CashierStore', component: () => import('@/views/cashier/store/Index.vue'), meta: { i18n: M.cashierStore } },
{ path: 'cashier/room', name: 'CashierRoom', component: () => import('@/views/cashier/room/Index.vue'), meta: { i18n: M.cashierRoom } },
{ path: 'cashier/pricing', name: 'CashierPricing', component: () => import('@/views/cashier/pricing/Index.vue'), meta: { i18n: M.cashierPricing } },
{ path: 'cashier/order', name: 'CashierOrder', component: () => import('@/views/cashier/order/Index.vue'), meta: { i18n: M.cashierOrder } },
{ path: 'cashier/product', name: 'CashierProduct', component: () => import('@/views/cashier/product/Index.vue'), meta: { i18n: M.cashierProduct } },
{ path: 'cashier/report', name: 'CashierReport', component: () => import('@/views/cashier/report/Index.vue'), meta: { i18n: M.cashierReport } },
]
-13
View File
@@ -1,13 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
cmsArticle: 'menu.cmsArticle',
cmsCategory: 'menu.cmsCategory',
cmsBanner: 'menu.cmsBanner',
}
export const cmsRoutes: RouteRecordRaw[] = [
{ path: 'cms/article', name: 'CmsArticle', component: () => import('@/views/cms/article/Index.vue'), meta: { i18n: M.cmsArticle } },
{ path: 'cms/category', name: 'CmsCategory', component: () => import('@/views/cms/category/Index.vue'), meta: { i18n: M.cmsCategory } },
{ path: 'cms/banner', name: 'CmsBanner', component: () => import('@/views/cms/banner/Index.vue'), meta: { i18n: M.cmsBanner } },
]
-38
View File
@@ -1,38 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
/**
* 核心路由 - 所有系统默认包含
*/
export const coreRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/Index.vue'),
meta: { hidden: true },
},
{
path: '/',
component: () => import('@/layout/DefaultLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Index.vue'),
meta: { i18n: 'menu.home' },
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/views/profile/Index.vue'),
meta: { i18n: 'common.personal', hidden: true },
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/Index.vue'),
meta: { i18n: 'menu.settings' },
},
],
},
]
-11
View File
@@ -1,11 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
demoIcons: 'menu.demoIcons',
demoList: 'menu.demoList',
}
export const demoRoutes: RouteRecordRaw[] = [
{ path: 'demo/icons', name: 'DemoIcons', component: () => import('@/views/demo/Icons.vue'), meta: { i18n: M.demoIcons } },
{ path: 'demo/list', name: 'DemoList', component: () => import('@/views/demo/list/Index.vue'), meta: { i18n: M.demoList } },
]
-11
View File
@@ -1,11 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
coupon: 'menu.marketingCoupon',
activity: 'menu.marketingActivity',
}
export const marketingRoutes: RouteRecordRaw[] = [
{ path: 'marketing/coupon', name: 'MarketingCoupon', component: () => import('@/views/marketing/coupon/Index.vue'), meta: { i18n: M.coupon } },
{ path: 'marketing/activity', name: 'MarketingActivity', component: () => import('@/views/marketing/activity/Index.vue'), meta: { i18n: M.activity } },
]
-11
View File
@@ -1,11 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
orderList: 'menu.orderList',
orderRefund: 'menu.orderRefund',
}
export const orderRoutes: RouteRecordRaw[] = [
{ path: 'order/list', name: 'OrderList', component: () => import('@/views/order/list/Index.vue'), meta: { i18n: M.orderList } },
{ path: 'order/refund', name: 'OrderRefund', component: () => import('@/views/order/refund/Index.vue'), meta: { i18n: M.orderRefund } },
]
-31
View File
@@ -1,31 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
systemMenu: 'menu.systemMenu',
systemRole: 'menu.systemRole',
systemDept: 'menu.systemDept',
systemPost: 'menu.systemPost',
systemDict: 'menu.systemDict',
systemConfig: 'menu.systemConfig',
systemLog: 'menu.systemLog',
systemLoginLog: 'menu.systemLoginLog',
systemTenant: 'menu.systemTenant',
systemTenantPackage: 'menu.systemTenantPackage',
systemDataScope: 'menu.systemDataScope',
systemOAuth2Client: 'menu.systemOAuth2Client',
}
export const systemRoutes: RouteRecordRaw[] = [
{ path: 'system/menu', name: 'SystemMenu', component: () => import('@/views/system/menu/Index.vue'), meta: { i18n: M.systemMenu } },
{ path: 'system/role', name: 'SystemRole', component: () => import('@/views/system/role/Index.vue'), meta: { i18n: M.systemRole } },
{ path: 'system/dept', name: 'SystemDept', component: () => import('@/views/system/dept/Index.vue'), meta: { i18n: M.systemDept } },
{ path: 'system/post', name: 'SystemPost', component: () => import('@/views/system/post/Index.vue'), meta: { i18n: M.systemPost } },
{ path: 'system/dict', name: 'SystemDict', component: () => import('@/views/system/dict/Index.vue'), meta: { i18n: M.systemDict } },
{ path: 'system/config', name: 'SystemConfig', component: () => import('@/views/system/config/Index.vue'), meta: { i18n: M.systemConfig } },
{ path: 'system/log', name: 'SystemLog', component: () => import('@/views/system/log/Index.vue'), meta: { i18n: M.systemLog } },
{ path: 'system/login-log', name: 'SystemLoginLog', component: () => import('@/views/system/login-log/Index.vue'), meta: { i18n: M.systemLoginLog } },
{ path: 'system/tenant', name: 'SystemTenant', component: () => import('@/views/system/tenant/Index.vue'), meta: { i18n: M.systemTenant } },
{ path: 'system/tenant-package', name: 'SystemTenantPackage', component: () => import('@/views/system/tenant-package/Index.vue'), meta: { i18n: M.systemTenantPackage } },
{ path: 'system/data-scope', name: 'SystemDataScope', component: () => import('@/views/system/data-scope/Index.vue'), meta: { i18n: M.systemDataScope } },
{ path: 'system/oauth2-client', name: 'SystemOAuth2Client', component: () => import('@/views/system/oauth2-client/Index.vue'), meta: { i18n: M.systemOAuth2Client } },
]
-19
View File
@@ -1,19 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
userInfo: 'menu.userInfo',
userDetail: 'menu.userDetail',
level: 'menu.levelList',
levelLog: 'menu.levelLog',
address: 'menu.address',
account: 'menu.account',
}
export const userRoutes: RouteRecordRaw[] = [
{ path: 'user/info', name: 'UserInfo', component: () => import('@/views/user/info/Index.vue'), meta: { i18n: M.userInfo } },
{ path: 'user/detail', name: 'UserDetail', component: () => import('@/views/user/detail/Index.vue'), meta: { i18n: M.userDetail } },
{ path: 'user/level', name: 'UserLevel', component: () => import('@/views/user/level/Index.vue'), meta: { i18n: M.level } },
{ path: 'user/level-log', name: 'UserLevelLog', component: () => import('@/views/user/level-log/Index.vue'), meta: { i18n: M.levelLog } },
{ path: 'user/address', name: 'UserAddress', component: () => import('@/views/user/address/Index.vue'), meta: { i18n: M.address } },
{ path: 'user/account', name: 'UserAccount', component: () => import('@/views/user/account/Index.vue'), meta: { i18n: M.account } },
]
-163
View File
@@ -1,163 +0,0 @@
import { request } from '@/utils/request'
/**
* 分页查询结果
*/
export interface PageResult<T = any> {
/** 列表数据 */
list: T[]
/** 总记录数 */
total: number
}
/**
* 分页参数
*/
export interface PageParams {
page: number
size: number
}
/**
* 通用 Service 基类
*
* <p>封装标准 CRUD 操作,子类只需传入 baseUrl 即可使用:</p>
*
* <pre>
* class UserService extends BaseService {
* constructor() {
* super('/user/admin/user')
* }
* }
* </pre>
*/
export class BaseService<T = any> {
/** 接口基础路径 */
protected baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
/**
* 分页查询
*
* @param params 分页参数 + 查询条件
* @returns 分页结果
*/
async page(params: PageParams & Record<string, any>): Promise<PageResult<T>> {
const res: any = await request({
url: `${this.baseUrl}/page`,
method: 'get',
params,
})
return {
list: res.data?.records || [],
total: res.data?.total || 0,
}
}
/**
* 列表查询
*
* @param params 查询条件
* @returns 数据列表
*/
async list(params?: Record<string, any>): Promise<T[]> {
const res: any = await request({
url: `${this.baseUrl}/list`,
method: 'get',
params,
})
return res.data || []
}
/**
* 根据 ID 查询详情
*
* @param id 主键 ID
* @returns 实体数据
*/
async getById(id: number | string): Promise<T> {
const res: any = await request({
url: `${this.baseUrl}/${id}`,
method: 'get',
})
return res.data
}
/**
* 新增
*
* @param data 实体数据
* @returns 新增后的实体(包含生成的ID)
*/
async add(data: Partial<T>): Promise<T> {
const res: any = await request({
url: this.baseUrl,
method: 'post',
data,
})
return res.data
}
/**
* 修改
*
* @param data 实体数据(必须包含 id)
* @returns 是否成功
*/
async update(data: Partial<T> & { id: number | string }): Promise<boolean> {
const res: any = await request({
url: this.baseUrl,
method: 'put',
data,
})
return res.data === true
}
/**
* 删除
*
* @param id 主键 ID
* @returns 是否成功
*/
async remove(id: number | string): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/${id}`,
method: 'delete',
})
return res.data === true
}
/**
* 批量删除(调用后端批量删除接口)
*
* @param ids 主键 ID 列表
* @returns 是否成功
*/
async batchRemove(ids: (number | string)[]): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/batch`,
method: 'delete',
data: ids,
})
return res.data === true
}
/**
* 状态切换(启用/禁用)
*
* @param id 主键 ID
* @param status 状态值(0禁用 1启用)
* @returns 是否成功
*/
async changeStatus(id: number | string, status: number): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/status`,
method: 'put',
params: { id, status },
})
return res.data === true
}
}
-100
View File
@@ -1,100 +0,0 @@
import { request } from '@/utils/request'
/**
* OAuth2 Token 响应
*/
export interface TokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
scope?: string
tenantId?: number
}
/**
* 登录参数
*/
export interface LoginParams {
username: string
password: string
tenantId?: string
}
/**
* 认证服务
*/
class AuthService {
/**
* OAuth2 密码模式登录
*/
async login(params: LoginParams): Promise<TokenResponse> {
const formData = new URLSearchParams()
formData.append('grant_type', 'password')
formData.append('username', params.username)
formData.append('password', params.password)
if (params.tenantId) {
formData.append('tenantId', params.tenantId)
}
const res: any = await request({
url: '/oauth2/token',
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + import.meta.env.VITE_OAUTH2_CLIENT_SECRET,
},
data: formData,
})
return res.data
}
/**
* 刷新 Token
*/
async refreshToken(refreshToken: string): Promise<TokenResponse> {
const formData = new URLSearchParams()
formData.append('grant_type', 'refresh_token')
formData.append('refresh_token', refreshToken)
const res: any = await request({
url: '/oauth2/token',
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + import.meta.env.VITE_OAUTH2_CLIENT_SECRET,
},
data: formData,
})
return res.data
}
/**
* 获取当前登录用户信息
*/
async getUserInfo(): Promise<any> {
const res: any = await request({
url: '/user/admin/user/current',
method: 'get',
})
return res.data
}
/**
* 登出
*/
async logout(): Promise<void> {
try {
await request({
url: '/oauth2/revoke',
method: 'post',
})
} catch {
// 即使后端登出失败,前端也要清理本地状态
}
}
}
export const authService = new AuthService()
@@ -1,68 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 订单服务
*/
class OrderService extends BaseService {
constructor() {
super('/cashier/admin/order')
}
/**
* 开台
*/
async openRoom(data: {
storeId: number
roomId: number
customerName?: string
customerPhone?: string
orderType?: number
remark?: string
}): Promise<any> {
const res: any = await request({
url: `${this.baseUrl}/open`,
method: 'post',
data,
})
return res.data
}
/**
* 结账
*/
async checkout(id: number): Promise<any> {
const res: any = await request({
url: `${this.baseUrl}/${id}/checkout`,
method: 'post',
})
return res.data
}
/**
* 支付
*/
async pay(id: number, data: any): Promise<any> {
const res: any = await request({
url: `${this.baseUrl}/${id}/pay`,
method: 'post',
data,
})
return res.data
}
/**
* 退款
*/
async refund(id: number, data: { amount: number; reason: string }): Promise<any> {
const res: any = await request({
url: `${this.baseUrl}/${id}/refund`,
method: 'post',
data,
})
return res.data
}
}
/** 订单服务单例 */
export const orderService = new OrderService()
@@ -1,61 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 定价策略服务
*/
class PricingService extends BaseService {
constructor() {
super('/cashier/admin/pricing-strategy')
}
/**
* 查询策略下的套餐列表
*/
async getPackages(strategyId: number): Promise<any[]> {
const res: any = await request({
url: `/cashier/admin/pricing-package/list`,
method: 'get',
params: { strategyId },
})
return res.data || []
}
/**
* 新增套餐
*/
async addPackage(data: any) {
const res: any = await request({
url: '/cashier/admin/pricing-package',
method: 'post',
data,
})
return res.data
}
/**
* 修改套餐
*/
async updatePackage(data: any) {
const res: any = await request({
url: '/cashier/admin/pricing-package',
method: 'put',
data,
})
return res.data
}
/**
* 删除套餐
*/
async deletePackage(id: number) {
const res: any = await request({
url: `/cashier/admin/pricing-package/${id}`,
method: 'delete',
})
return res.data
}
}
/** 定价策略服务单例 */
export const pricingService = new PricingService()
@@ -1,13 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 商品服务
*/
class ProductService extends BaseService {
constructor() {
super('/cashier/admin/product')
}
}
/** 商品服务单例 */
export const productService = new ProductService()
@@ -1,33 +0,0 @@
import { request } from '@/utils/request'
/**
* 报表服务
*/
class ReportService {
/**
* 营业日报
*/
async getDailyReport(storeId: number, date: string): Promise<any> {
const res: any = await request({
url: '/cashier/admin/report/daily',
method: 'get',
params: { storeId, date },
})
return res.data
}
/**
* 包间利用率
*/
async getRoomUsage(storeId: number, date: string): Promise<any[]> {
const res: any = await request({
url: '/cashier/admin/report/room-usage',
method: 'get',
params: { storeId, date },
})
return res.data || []
}
}
/** 报表服务单例 */
export const reportService = new ReportService()
@@ -1,13 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 包间服务
*/
class RoomService extends BaseService {
constructor() {
super('/cashier/admin/room')
}
}
/** 包间服务单例 */
export const roomService = new RoomService()
@@ -1,13 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 门店服务
*/
class StoreService extends BaseService {
constructor() {
super('/cashier/admin/store')
}
}
/** 门店服务单例 */
export const storeService = new StoreService()
@@ -1,12 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 参数配置 Service
*/
class ConfigService extends BaseService {
constructor() {
super('/system/admin/config')
}
}
export const configService = new ConfigService()
@@ -1,36 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 数据权限服务
*/
class DataScopeService extends BaseService {
constructor() {
super('/system/admin/data-scope')
}
/**
* 查询角色已分配的部门ID列表
*/
async listDeptIdsByRoleId(roleId: number | string): Promise<number[]> {
const res: any = await request({
url: `${this.baseUrl}/role/${roleId}`,
method: 'get',
})
return res.data || []
}
/**
* 分配角色数据权限(自定义部门范围)
*/
async assignDataScope(roleId: number | string, deptIds: number[]): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/role/${roleId}`,
method: 'post',
data: deptIds,
})
return res.data === true
}
}
export const dataScopeService = new DataScopeService()
@@ -1,25 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 部门服务
*/
class DeptService extends BaseService {
constructor() {
super('/system/admin/dept')
}
/**
* 查询部门树
*/
async tree(): Promise<any[]> {
const res: any = await request({
url: '/system/admin/dept/list',
method: 'get',
})
return res.data || []
}
}
/** 部门服务单例 */
export const deptService = new DeptService()
@@ -1,13 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 字典项服务
*/
class DictItemService extends BaseService {
constructor() {
super('/system/admin/dict/item')
}
}
/** 字典项服务单例 */
export const dictItemService = new DictItemService()
@@ -1,12 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 字典类型 Service
*/
class DictService extends BaseService {
constructor() {
super('/system/admin/dict/type')
}
}
export const dictService = new DictService()
-8
View File
@@ -1,8 +0,0 @@
export { menuService } from './menuService'
export { tenantService } from './tenantService'
export { roleService } from './roleService'
export { deptService } from './deptService'
export { dictService } from './dictService'
export { dictItemService } from './dictItemService'
export { configService } from './configService'
export { oauth2ClientService } from './oauth2ClientService'
@@ -1,25 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 登录日志服务
*/
class LoginLogService extends BaseService {
constructor() {
super('/system/admin/login-log')
}
/**
* 清空日志
*/
async clear(): Promise<boolean> {
const res: any = await request({
url: '/system/admin/login-log/clear',
method: 'delete',
})
return res.data === true
}
}
/** 登录日志服务单例 */
export const loginLogService = new LoginLogService()
@@ -1,40 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 菜单服务
*/
class MenuService extends BaseService {
constructor() {
super('/system/admin/menu')
}
/**
* 查询菜单树(菜单管理用,返回全部菜单)
* @param platform 所属平台 admin|app
*/
async tree(platform?: string): Promise<any[]> {
const res: any = await request({
url: '/system/admin/menu/tree',
method: 'get',
params: platform ? { platform } : undefined,
})
return res.data || []
}
/**
* 查询当前用户菜单树(框架侧边栏用,按角色权限过滤)
* @param platform 所属平台 admin|app
*/
async userTree(platform?: string): Promise<any[]> {
const res: any = await request({
url: '/system/admin/menu/user-tree',
method: 'get',
params: platform ? { platform } : undefined,
})
return res.data || []
}
}
/** 菜单服务单例 */
export const menuService = new MenuService()
@@ -1,13 +0,0 @@
import { BaseService } from '../BaseService'
/**
* OAuth2 客户端服务
*/
class OAuth2ClientService extends BaseService {
constructor() {
super('/system/admin/oauth2-client')
}
}
/** OAuth2 客户端服务单例 */
export const oauth2ClientService = new OAuth2ClientService()
@@ -1,25 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 操作日志服务
*/
class OperLogService extends BaseService {
constructor() {
super('/system/admin/log')
}
/**
* 清空日志
*/
async clear(): Promise<boolean> {
const res: any = await request({
url: '/system/admin/log/clear',
method: 'delete',
})
return res.data === true
}
}
/** 操作日志服务单例 */
export const operLogService = new OperLogService()
@@ -1,12 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 岗位服务
*/
class PostService extends BaseService {
constructor() {
super('/system/admin/post')
}
}
export const postService = new PostService()
@@ -1,13 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 角色服务
*/
class RoleService extends BaseService {
constructor() {
super('/system/admin/role')
}
}
/** 角色服务单例 */
export const roleService = new RoleService()
@@ -1,12 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 租户套餐服务
*/
class TenantPackageService extends BaseService {
constructor() {
super('/system/admin/tenant-package')
}
}
export const tenantPackageService = new TenantPackageService()
@@ -1,60 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 租户服务
*/
class TenantService extends BaseService {
constructor() {
super('/system/admin/tenant')
}
/**
* 初始化租户
*/
async initTenant(tenantId: number | string): Promise<boolean> {
const res: any = await request({
url: `/system/admin/tenant/${tenantId}/init`,
method: 'post',
})
return res.error === 0
}
/**
* 修改租户管理员密码
*/
async updateAdminPassword(tenantId: number | string, newPassword: string): Promise<boolean> {
const res: any = await request({
url: `/system/admin/tenant/${tenantId}/admin/password`,
method: 'post',
data: { newPassword },
})
return res.error === 0
}
/**
* 获取租户已启用模块
*/
async getModules(tenantId: number | string): Promise<string> {
const res: any = await request({
url: `/system/admin/tenant/${tenantId}/modules`,
method: 'get',
})
return res.data || ''
}
/**
* 同步租户模块
*/
async syncModules(tenantId: number | string, modules: string): Promise<boolean> {
const res: any = await request({
url: `/system/admin/tenant/${tenantId}/modules`,
method: 'post',
data: { modules },
})
return res.error === 0
}
}
/** 租户服务单例 */
export const tenantService = new TenantService()
-6
View File
@@ -1,6 +0,0 @@
export { userService } from './userService'
export { userDetailService } from './userDetailService'
export { userDeptService } from './userDeptService'
export { userPostService } from './userPostService'
export { levelService } from './levelService'
export { levelLogService } from './levelLogService'
@@ -1,13 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 用户等级日志服务
*/
class LevelLogService extends BaseService {
constructor() {
super('/user/admin/level-log')
}
}
/** 用户等级日志服务单例 */
export const levelLogService = new LevelLogService()
-13
View File
@@ -1,13 +0,0 @@
import { BaseService } from '../BaseService'
/**
* 用户等级服务
*/
class LevelService extends BaseService {
constructor() {
super('/user/admin/level')
}
}
/** 用户等级服务单例 */
export const levelService = new LevelService()
@@ -1,34 +0,0 @@
import { request } from '@/utils/request'
/**
* 用户部门关联服务
*/
class UserDeptService {
private baseUrl = '/user/admin/user-dept'
/**
* 查询用户已分配的部门ID列表
*/
async listDeptIdsByUserId(userId: number | string): Promise<number[]> {
const res: any = await request({
url: `${this.baseUrl}/user/${userId}`,
method: 'get',
})
return res.data || []
}
/**
* 分配用户部门
*/
async assignDepts(userId: number | string, deptIds: number[]): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/user/${userId}`,
method: 'post',
data: deptIds,
})
return res.data === true
}
}
/** 用户部门关联服务单例 */
export const userDeptService = new UserDeptService()
@@ -1,12 +0,0 @@
import { BaseService } from '@/service/BaseService'
/**
* 用户详情服务
*/
class UserDetailService extends BaseService {
constructor() {
super('/user/admin/detail')
}
}
export const userDetailService = new UserDetailService()
@@ -1,34 +0,0 @@
import { request } from '@/utils/request'
/**
* 用户岗位关联服务
*/
class UserPostService {
private baseUrl = '/user/admin/user-post'
/**
* 查询用户已分配的岗位ID列表
*/
async listPostIdsByUserId(userId: number | string): Promise<number[]> {
const res: any = await request({
url: `${this.baseUrl}/user/${userId}`,
method: 'get',
})
return res.data || []
}
/**
* 分配用户岗位
*/
async assignPosts(userId: number | string, postIds: number[]): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/user/${userId}`,
method: 'post',
data: postIds,
})
return res.data === true
}
}
/** 用户岗位关联服务单例 */
export const userPostService = new UserPostService()
-39
View File
@@ -1,39 +0,0 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 用户服务
*
* <p>封装用户相关 API 调用,继承 BaseService 获得标准 CRUD 能力</p>
*/
class UserService extends BaseService {
constructor() {
super('/user/admin/user')
}
/**
* 获取用户已分配的角色列表
*/
async getRoles(userId: number | string): Promise<number[]> {
const res: any = await request({
url: `/user/admin/user/${userId}/roles`,
method: 'get',
})
return res.data || []
}
/**
* 分配角色
*/
async assignRoles(userId: number | string, roleIds: number[]): Promise<boolean> {
const res: any = await request({
url: `/user/admin/user/${userId}/roles`,
method: 'post',
data: roleIds,
})
return res.error === 0
}
}
/** 用户服务单例 */
export const userService = new UserService()
-24
View File
@@ -1,24 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const dark = ref(localStorage.getItem('dark') === 'true')
const lang = ref(localStorage.getItem('lang') || 'zh-CN')
const layout = ref(localStorage.getItem('layout') || 'double')
const primaryColor = ref(localStorage.getItem('primaryColor') || '#1677ff')
const themeVisible = ref(false)
const pageFullscreen = ref(false)
function toggleDark() { dark.value = !dark.value; localStorage.setItem('dark', String(dark.value)); applyDark() }
function setLang(l: string) { lang.value = l; localStorage.setItem('lang', l) }
function setLayout(l: string) { layout.value = l; localStorage.setItem('layout', l) }
function setPrimaryColor(c: string) { primaryColor.value = c; localStorage.setItem('primaryColor', c); document.documentElement.style.setProperty('--el-color-primary', c) }
function togglePageFullscreen() { pageFullscreen.value = !pageFullscreen.value }
function logout() { localStorage.removeItem('token'); window.location.hash = '#/login' }
function applyDark() { dark.value ? document.documentElement.classList.add('dark') : document.documentElement.classList.remove('dark') }
applyDark()
document.documentElement.style.setProperty('--el-color-primary', primaryColor.value)
return { dark, lang, layout, primaryColor, themeVisible, pageFullscreen, toggleDark, setLang, setLayout, setPrimaryColor, togglePageFullscreen, logout, applyDark }
})
-158
View File
@@ -1,158 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authService } from '@/service/authService'
import type { TokenResponse, LoginParams } from '@/service/authService'
import router from '@/router'
export interface UserInfo {
userId?: number
username?: string
nickname?: string
avatar?: string
tenantId?: number
[key: string]: any
}
export const useAuthStore = defineStore('auth', () => {
// ============ State ============
const token = ref<TokenResponse | null>(null)
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!token.value?.access_token)
// ============ Getters ============
const accessToken = computed(() => token.value?.access_token || '')
const username = computed(() => userInfo.value?.nickname || userInfo.value?.username || '')
const avatar = computed(() => userInfo.value?.avatar || '')
const tenantId = computed(() => userInfo.value?.tenantId || token.value?.tenantId || 0)
// ============ Actions ============
/**
* 从 localStorage 加载认证状态
*/
function loadAuth() {
try {
const tokenStr = localStorage.getItem('token')
if (tokenStr) {
token.value = JSON.parse(tokenStr)
}
const userStr = localStorage.getItem('user')
if (userStr) {
userInfo.value = JSON.parse(userStr)
}
} catch {
clearAuth()
}
}
/**
* 保存认证状态到 localStorage
*/
function saveAuth(tokenData: TokenResponse, user?: UserInfo) {
token.value = tokenData
localStorage.setItem('token', JSON.stringify(tokenData))
if (user) {
userInfo.value = user
localStorage.setItem('user', JSON.stringify(user))
}
}
/**
* 清除认证状态
*/
function clearAuth() {
token.value = null
userInfo.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('tenantId')
}
/**
* 登录
*/
async function login(params: LoginParams): Promise<boolean> {
try {
const tokenData = await authService.login(params)
saveAuth(tokenData)
// 如果有租户ID,保存到 localStorage
if (params.tenantId) {
localStorage.setItem('tenantId', params.tenantId)
}
// 获取用户信息
await fetchUserInfo()
return true
} catch {
return false
}
}
/**
* 获取用户信息
*/
async function fetchUserInfo(): Promise<boolean> {
try {
const user = await authService.getUserInfo()
userInfo.value = {
userId: user.id,
username: user.username,
nickname: user.nickName || user.nickname || user.username,
avatar: user.avatar,
tenantId: user.tenantId,
...user,
}
localStorage.setItem('user', JSON.stringify(userInfo.value))
return true
} catch {
return false
}
}
/**
* 登出
*/
async function logout(): Promise<void> {
await authService.logout()
clearAuth()
router.push('/login')
}
/**
* 刷新 Token
*/
async function refreshAccessToken(): Promise<boolean> {
const refreshToken = token.value?.refresh_token
if (!refreshToken) return false
try {
const tokenData = await authService.refreshToken(refreshToken)
saveAuth(tokenData)
return true
} catch {
// 刷新失败,需要重新登录
clearAuth()
return false
}
}
// ============ 初始化加载 ============
loadAuth()
return {
token,
userInfo,
isLoggedIn,
accessToken,
username,
avatar,
tenantId,
login,
logout,
fetchUserInfo,
refreshAccessToken,
clearAuth,
}
})
-47
View File
@@ -1,47 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
export interface TagItem { path: string; title: string }
export const useTagsStore = defineStore('tags', () => {
const visited = ref<TagItem[]>(load())
function load() {
const saved = JSON.parse(localStorage.getItem('tags') || '[]') as TagItem[]
const hasHome = saved.some(t => t.path === '/dashboard')
if (!hasHome) saved.unshift({ path: '/dashboard', title: 'menu.home' })
return saved
}
function addTag(route: RouteLocationNormalized) {
if (route.meta.hidden) return
const title = (route.meta.i18n as string) || route.name as string || ''
if (!title) return
const existIdx = visited.value.findIndex(t => t.path === route.path)
if (existIdx >= 0) return
visited.value.push({ path: route.path, title })
save()
}
function removeTag(path: string) {
visited.value = visited.value.filter(t => t.path !== path)
save()
}
function closeOther(path: string) {
visited.value = visited.value.filter(t => t.path === path || t.path === '/dashboard')
save()
}
function closeAll() {
visited.value = visited.value.filter(t => t.path === '/dashboard')
save()
}
function save() {
localStorage.setItem('tags', JSON.stringify(visited.value))
}
return { visited, addTag, removeTag, closeOther, closeAll }
})
-46
View File
@@ -1,46 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface UserInfo {
username?: string
nickname?: string
avatar?: string
[key: string]: any
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
/**
* 从 localStorage 加载用户信息
*/
function loadUserInfo() {
try {
const userStr = localStorage.getItem('user')
if (userStr) {
const data = JSON.parse(userStr)
// 兼容不同后端返回格式:可能直接是用户对象,也可能是 token 对象
userInfo.value = {
username: data.username || data.user_name || data.name || 'Admin',
nickname: data.nickname || data.nickName || data.username || data.user_name || 'Admin',
avatar: data.avatar || data.headImgUrl || '',
...data,
}
}
} catch {
userInfo.value = null
}
}
const username = computed(() => userInfo.value?.nickname || userInfo.value?.username || 'Admin')
const avatar = computed(() => userInfo.value?.avatar || '')
function clearUser() {
userInfo.value = null
}
// 初始化时加载
loadUserInfo()
return { userInfo, username, avatar, loadUserInfo, clearUser }
})
-75
View File
@@ -1,75 +0,0 @@
/**
* 系统构建配置
*/
export interface BuildConfig {
/** 系统唯一标识,产物目录名 */
key: string
/** 系统显示名称 */
name: string
/** 系统描述 */
description?: string
/** 包含的模块列表 */
modules: string[]
/** 登录页配置 */
login: LoginConfig
/** Dashboard配置 */
dashboard: DashboardConfig
/** 主题配置 */
theme: ThemeConfig
}
/**
* 登录页配置
*/
export interface LoginConfig {
/** 登录组件名(对应 views/login/systems/ 下的组件) */
component: string
/** 是否显示租户ID输入 */
showTenantInput: boolean
/** 页面标题 */
title: string
/** 副标题 */
subtitle?: string
/** 背景图路径 */
background?: string
/** Logo路径 */
logo?: string
}
/**
* Dashboard配置
*/
export interface DashboardConfig {
/** Dashboard组件名(对应 views/dashboard/systems/ 下的组件) */
component: string
/** 页面标题 */
title: string
}
/**
* 主题配置
*/
export interface ThemeConfig {
/** 主题色 */
primaryColor: string
/** 页面标题 */
title: string
}
/**
* 虚拟模块:生成的路由配置
*/
declare module 'virtual:generated-routes' {
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[]
export default routes
}
/**
* 虚拟模块:系统配置
*/
declare module 'virtual:system-config' {
import type { BuildConfig } from './system-config'
const config: BuildConfig
export default config
}
-150
View File
@@ -1,150 +0,0 @@
import axios from 'axios'
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import type { LoadingInstance } from 'element-plus/es/components/loading/src/loading'
const request = axios.create({
baseURL: '/api',
timeout: 10000,
})
// 全局 loading 实例
let loadingInstance: LoadingInstance | null = null
let requestCount = 0
/**
* 显示 loading
*/
function showLoading() {
if (requestCount === 0) {
loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.1)',
})
}
requestCount++
}
/**
* 隐藏 loading
*/
function hideLoading() {
requestCount--
if (requestCount <= 0) {
requestCount = 0
if (loadingInstance) {
loadingInstance.close()
loadingInstance = null
}
}
}
// 不需要显示 loading 的接口白名单
const noLoadingUrls = ['/oauth2/token']
// 请求拦截
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 显示 loading(白名单接口除外)
if (!noLoadingUrls.some(url => config.url?.includes(url))) {
showLoading()
}
// 透传租户编号至后端(优先级:Token中 > localStorage > 环境变量)
let tenantId = localStorage.getItem('tenantId') || import.meta.env.VITE_TENANT_ID
const tokenStr = localStorage.getItem('token')
if (tokenStr) {
try {
const token = JSON.parse(tokenStr)
config.headers.Authorization = `Bearer ${token.access_token}`
// Token中如有租户编号则使用token中的值,用于一键登录
if (token.tenantId) {
tenantId = token.tenantId
}
} catch (e) {
console.warn('Token解析失败', e)
}
}
if (tenantId) {
config.headers['X-Tenant-Id'] = tenantId
}
return config
},
(error) => {
hideLoading()
return Promise.reject(error)
},
)
// 响应拦截
request.interceptors.response.use(
(response: AxiosResponse) => {
hideLoading()
const { data } = response
// 业务错误处理
if (data.error !== 0) {
// 特殊错误码处理
if (data.code === 403) {
ElMessage.error('无权访问该资源')
} else {
ElMessage.error(data.message || '请求失败')
}
return Promise.reject(data)
}
return data
},
(error) => {
hideLoading()
// 网络错误处理
if (!error.response) {
if (error.message?.includes('timeout')) {
ElMessage.error('请求超时,请稍后重试')
} else if (error.message?.includes('Network Error')) {
ElMessage.error('网络连接失败,请检查网络')
} else {
ElMessage.error('服务器连接失败')
}
return Promise.reject(error)
}
// HTTP 状态码处理
const status = error.response.status
switch (status) {
case 400:
ElMessage.error('请求参数错误')
break
case 401:
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('tenantId')
window.location.hash = '#/login'
break
case 403:
ElMessage.error('无权访问该资源')
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 500:
ElMessage.error('服务器内部错误')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务暂不可用')
break
default:
ElMessage.error(`请求失败: ${status}`)
}
return Promise.reject(error)
},
)
export { request }
export default request
-198
View File
@@ -1,198 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orderService } from '@/service/cashier/orderService'
import RuiTable from '@/components/RuiTable.vue'
import type { TableColumn } from '@/components/RuiTable.vue'
import OpenRoomDialog from './OpenRoomDialog.vue'
/**
* 表格列配置
*/
const columns: TableColumn[] = [
{ prop: 'orderNo', label: '订单编号', width: 150 },
{ prop: 'storeId', label: '门店ID', width: 100 },
{ prop: 'roomId', label: '包间ID', width: 100 },
{ prop: 'customerName', label: '顾客姓名', width: 100 },
{ prop: 'customerPhone', label: '顾客电话', width: 130 },
{ prop: 'totalAmount', label: '总金额', width: 100, align: 'right', dataType: 'money' },
{ prop: 'payAmount', label: '实付金额', width: 100, align: 'right', dataType: 'money' },
{
prop: 'payStatus',
label: '支付状态',
width: 100,
align: 'center',
dataType: 'enum',
enumMap: { 0: '未支付', 1: '部分支付', 2: '已支付' },
},
{ prop: 'payType', label: '支付方式', width: 100 },
{
prop: 'status',
label: '订单状态',
width: 100,
align: 'center',
dataType: 'enum',
enumMap: { 0: '开台中', 1: '已挂单', 2: '待支付', 3: '已完成', 4: '已退款' },
},
{ prop: 'createTime', label: '创建时间', width: 160, dataType: 'dateTime' },
]
/**
* 表格引用
*/
const tableRef = ref<InstanceType<typeof RuiTable>>()
const openRoomVisible = ref(false)
/**
* 查询参数
*/
const queryParams = ref({
orderNo: '',
status: undefined as number | undefined,
payStatus: undefined as number | undefined,
})
/**
* 加载数据
*/
async function loadData(params: any) {
return await orderService.page(params)
}
/**
* 查询
*/
function handleSearch() {
tableRef.value?.setQueryParams(queryParams.value)
tableRef.value?.search()
}
/**
* 重置
*/
function handleReset() {
queryParams.value = {
orderNo: '',
status: undefined,
payStatus: undefined,
}
tableRef.value?.reset()
}
/**
* 开台
*/
function handleOpen() {
openRoomVisible.value = true
}
/**
* 结账
*/
function handleCheckout(row: any) {
ElMessageBox.confirm(`确认为订单 "${row.orderNo}" 结账吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await orderService.checkout(row.id)
ElMessage.success('结账成功')
tableRef.value?.refresh()
} catch {}
}).catch(() => {})
}
/**
* 退款
*/
function handleRefund(row: any) {
ElMessageBox.prompt('请输入退款金额', '退款', {
inputType: 'number',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async ({ value }: any) => {
try {
await orderService.refund(row.id, { amount: Number(value), reason: '用户申请退款' })
ElMessage.success('退款成功')
tableRef.value?.refresh()
} catch {}
}).catch(() => {})
}
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">订单管理</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
>
<!-- 查询区域 -->
<template #search>
<el-form-item label="订单编号">
<el-input v-model="queryParams.orderNo" placeholder="请输入订单编号" clearable />
</el-form-item>
<el-form-item label="订单状态">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="开台中" :value="0" />
<el-option label="已挂单" :value="1" />
<el-option label="待支付" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已退款" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="支付状态">
<el-select v-model="queryParams.payStatus" placeholder="请选择状态" clearable>
<el-option label="未支付" :value="0" />
<el-option label="部分支付" :value="1" />
<el-option label="已支付" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</template>
<!-- 工具栏 -->
<template #toolbar-left>
<el-button type="primary" @click="handleOpen">开台</el-button>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button
v-if="row.status === 0 || row.status === 1"
link
type="primary"
size="small"
@click="handleCheckout(row)"
>
结账
</el-button>
<el-button
v-if="row.status === 3 && row.payStatus === 2"
link
type="warning"
size="small"
@click="handleRefund(row)"
>
退款
</el-button>
</template>
</RuiTable>
<OpenRoomDialog
v-model:visible="openRoomVisible"
@success="tableRef?.refresh()"
/>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
</style>
@@ -1,195 +0,0 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { orderService } from '@/service/cashier/orderService'
import { storeService } from '@/service/cashier/storeService'
import { roomService } from '@/service/cashier/roomService'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'success'): void
}>()
// 表单数据
const form = reactive({
storeId: undefined as number | undefined,
roomId: undefined as number | undefined,
customerName: '',
customerPhone: '',
orderType: 1,
remark: '',
})
// 表单引用
const formRef = ref()
// 加载状态
const loading = ref(false)
// 门店列表
const storeList = ref<any[]>([])
// 包间列表
const roomList = ref<any[]>([])
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
loadStores()
Object.assign(form, {
storeId: undefined,
roomId: undefined,
customerName: '',
customerPhone: '',
orderType: 1,
remark: '',
})
roomList.value = []
}
})
// 加载门店列表
async function loadStores() {
try {
const res = await storeService.list({ status: 1 })
storeList.value = res || []
} catch {
storeList.value = []
}
}
// 门店变化时加载包间列表
async function handleStoreChange(storeId: number) {
form.roomId = undefined
if (!storeId) {
roomList.value = []
return
}
try {
const res = await roomService.list({ storeId, enabled: 1 })
roomList.value = res || []
} catch {
roomList.value = []
}
}
// 表单校验规则
const rules = {
storeId: [
{ required: true, message: '请选择门店', trigger: 'change' },
],
roomId: [
{ required: true, message: '请选择包间', trigger: 'change' },
],
}
// 提交表单
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await orderService.openRoom({
storeId: form.storeId!,
roomId: form.roomId!,
customerName: form.customerName || undefined,
customerPhone: form.customerPhone || undefined,
orderType: form.orderType,
remark: form.remark || undefined,
})
ElMessage.success('开台成功')
emit('success')
emit('update:visible', false)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
// 关闭弹窗
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
class="rui-dialog"
title="开台"
:model-value="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="门店" prop="storeId">
<el-select
v-model="form.storeId"
placeholder="请选择门店"
clearable
@change="handleStoreChange"
>
<el-option
v-for="item in storeList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="包间" prop="roomId">
<el-select v-model="form.roomId" placeholder="请选择包间" clearable>
<el-option
v-for="item in roomList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="顾客姓名">
<el-input v-model.trim="form.customerName" placeholder="请输入顾客姓名" />
</el-form-item>
<el-form-item label="顾客电话">
<el-input v-model.trim="form.customerPhone" placeholder="请输入顾客电话" />
</el-form-item>
<el-form-item label="订单类型">
<el-radio-group v-model="form.orderType">
<el-radio-button :label="1">正常订单</el-radio-button>
<el-radio-button :label="2">预订订单</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model.trim="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
@@ -1,197 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pricingService } from '@/service/cashier/pricingService'
import RuiTable from '@/components/RuiTable.vue'
import type { TableColumn } from '@/components/RuiTable.vue'
import PricingStrategyFormDialog from './PricingStrategyFormDialog.vue'
import PricingPackageDialog from './PricingPackageDialog.vue'
/**
* 表格列配置
*/
const columns: TableColumn[] = [
{ prop: 'strategyName', label: '策略名称', minWidth: 150 },
{ prop: 'roomTypeId', label: '包间类型ID', width: 120 },
{
prop: 'status',
label: '状态',
width: 80,
align: 'center',
dataType: 'enum',
enumMap: { 0: '禁用', 1: '启用' },
slot: true,
},
]
/**
* 表格引用
*/
const tableRef = ref<InstanceType<typeof RuiTable>>()
/**
* 查询参数
*/
const queryParams = ref({
strategyName: '',
status: undefined as number | undefined,
})
/**
* 策略表单弹窗状态
*/
const strategyDialogVisible = ref(false)
const strategyRow = ref<any>(undefined)
/**
* 套餐管理弹窗状态
*/
const packageDialogVisible = ref(false)
const packageStrategyId = ref<number | undefined>(undefined)
/**
* 加载数据
*/
async function loadData(params: any) {
return await pricingService.page(params)
}
/**
* 查询
*/
function handleSearch() {
tableRef.value?.setQueryParams(queryParams.value)
tableRef.value?.search()
}
/**
* 重置
*/
function handleReset() {
queryParams.value = {
strategyName: '',
status: undefined,
}
tableRef.value?.reset()
}
/**
* 新增
*/
function handleAdd() {
strategyRow.value = undefined
strategyDialogVisible.value = true
}
/**
* 编辑
*/
function handleEdit(row: any) {
strategyRow.value = row
strategyDialogVisible.value = true
}
/**
* 删除
*/
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除策略 "${row.strategyName}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await pricingService.remove(row.id)
ElMessage.success('删除成功')
tableRef.value?.refresh()
} catch {}
}).catch(() => {})
}
/**
* 状态切换
*/
async function handleStatusChange(row: any, status: number) {
if (!row?.id) return
try {
await pricingService.changeStatus(row.id, status)
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
} catch {
row.status = status === 1 ? 0 : 1
}
}
/**
* 查看套餐
*/
function handleViewPackages(row: any) {
packageStrategyId.value = row.id
packageDialogVisible.value = true
}
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">定价策略管理</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
>
<!-- 查询区域 -->
<template #search>
<el-form-item label="策略名称">
<el-input v-model="queryParams.strategyName" placeholder="请输入策略名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</template>
<!-- 工具栏 -->
<template #toolbar-left>
<el-button type="primary" @click="handleAdd">新增策略</el-button>
</template>
<!-- 状态列 -->
<template #column-status="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="(val: any) => handleStatusChange(row, val as number)"
/>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="info" size="small" @click="handleViewPackages(row)">查看套餐</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</RuiTable>
<PricingStrategyFormDialog
v-model:visible="strategyDialogVisible"
:row="strategyRow"
@success="tableRef?.refresh()"
/>
<PricingPackageDialog
v-model:visible="packageDialogVisible"
:strategy-id="packageStrategyId"
/>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
</style>

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