Compare commits

..

41 Commits

Author SHA1 Message Date
liuyun
e1bc973034 ci: 前端 Dockerfile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 17:02:18 +08:00
liuyun
cf54ea2352 chore: P3 清理(rolldown 拼写/.env 入库/模板残留/死代码/确认取消/console 噪音)
- vite rolldownOptions 拼写修正
- .env 入库 → gitignore + .env.example
- package.json 移除模板 author/description、补 MIT
- 删除死代码 config/menu.ts、untrack stats.html
- ty-crud 删除确认取消不报 rejection、main.ts 移除 console 噪音

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:57:55 +08:00
liuyun
0a1fbf3df2 refactor: 前端时间类型 string → number(对齐 Unix 毫秒契约)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:54:19 +08:00
liuyun
77c9c3fd25 fix: useRequest 请求序号防竞态 + ty-crud 搜索/重置去重
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:46:13 +08:00
liuyun
ed1a1e115c fix: 401 强退重置 authStore + dictStore 支持 force 刷新
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:43:31 +08:00
liuyun
42b0a6a479 feat(auth): 登录多租户选择弹窗 + auth store 登录签名调整
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:41:26 +08:00
liuyun
b03e9ef62b feat(auth): 用户自助修改密码
- PATCH /auth/me/password(校验原密码 + tokenVersion 吊销其它会话)
- 顶栏用户下拉新增「修改密码」

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:34:14 +08:00
liuyun
23da579b26 feat(auth): 注册真实化 + 注册/忘记密码默认隐藏
- register.vue 接入 POST /auth/register
- defaultLoginConfig showRegister/showForgetPassword 默认 false

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:29:19 +08:00
liuyun
84edbc67c0 feat(rbac): 路由级权限过滤 + 403 页
- 路由守卫按可访问菜单 path 校验,无权跳转 /403
- 新增 403 页

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:22:19 +08:00
liuyun
2f7f83b8b1 feat(rbac): 按钮级权限控制(v-permission + 权限 store + 用户页导出)
- 新增 permission store + v-permission 指令(响应式,异步权限加载后可恢复)
- ty-crud 支持 createPermission 与动作 permission 过滤
- 用户页:分配角色/重置密码按权限码显示,新增导出按钮 + CSV 导出

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:19:53 +08:00
liuyun
7ae220581a fix(security): 角色资源树回显防权限放大 + 401 刷新防死循环
- assign-resources-form:只保存叶子键 getCheckedKeys(true),回显 setCheckedKeys(leafOnly)
- http.ts:_retry 标记只重放一次 + 排除 login/refresh

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 16:10:32 +08:00
liuyun
23e228783c feat: dict-driven rendering — useDict + component dictCode
- 新增 dict store 与 useDict Hook(登录后缓存、登出清空)
- ty-form/ty-table 支持 dictCode 自动解析下拉与标签
- 用户/角色/资源/组织/租户/审计/登录日志页去硬编码映射
- 字典管理页内置标记、value 保护、标签色编辑

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 14:29:28 +08:00
liuyun
c96151d2ae refactor: convert raw CSS to Tailwind @apply + search collapseCount=3
- ty-search-form 折叠默认数 2 → 3
- login/register/forgot/home/ty-header/ty-sidebar/ty-mixbar/ty-tabs/ty-footer/ty-setting-panel/icon/default 布局裸 CSS 转 @apply
- 修复背景色工具类误写(bg-base→bg-bg-base、bg-page→bg-bg-page)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 13:18:45 +08:00
liuyun
8742932f21 feat: home welcome page
- 登录后首页改为欢迎页:问候语 + 用户名 + 实时时间 + 菜单驱动的快捷入口卡片

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 13:03:42 +08:00
liuyun
7d0088dd10 feat: tenant management page
- api/tenant.ts + /system/tenant 页面(ty-crud)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 12:49:55 +08:00
liuyun
f1cb5723b2 feat: wire all management pages to backend (config/dict/org/resource/role/audit/login-log)
- 401 自动登出:http 会话过期回调 + main.ts 注册(清 menu/tabs + 跳登录)
- 参数配置/字典/组织/资源/角色/操作日志/登录日志对接真实接口
- 角色管理新增权限分配树(el-tree 勾选资源,/roles/:code/resources)
- 操作日志/登录日志只读页(ty-search-form + ty-table + useRequest)
- listOrgs 迁到 api/org.ts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 12:45:53 +08:00
liuyun
77917573c1 feat: real menu rendering + axios/useRequest integration
- axios OO 封装(src/api/http.ts):请求拦截鉴权头、拆 {code,message,data}、401 刷新重放
- useRequest composable(manual/refreshDeps/cancel/mutate)
- ty-crud 列表加载改用 useRequest,修复分页切换不重新请求
- 用户管理对接真实接口(列表/增删改/重置密码/组织下拉),ty-table 日期兼容 ISO
- 菜单真实渲染:GET /auth/menus + 菜单 store,布局/页签/面包屑从接口取
- 登录/注册/忘记密码去掉租户编码字段(用配置默认租户),暗黑模式品牌区背景适配
- 退出登录清空菜单 + 页签

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 12:19:24 +08:00
liuyun
c95aa594cb feat: configurable login page + register/forgot-password pages
- 登录页配置化:src/config/login.ts(品牌/布局/字段/开关),split/card 两种布局
- JsonSchemaProperty 新增 prefixIcon
- 注册 /register、忘记密码 /forgot-password 独立页面(blank 布局)
- 登录/注册/忘记密码统一 blank 布局(新增 blank.vue)
- 注册/重置成功后带 username 回填登录页

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 11:34:41 +08:00
liuyun
b9c3a64c34 feat: resource management page + 404 fallback + header fixes
- 资源管理:树形表格 + type 联动表单(menu/button/api 字段显隐)
- ty-form 新增 when 条件字段显隐;ty-crud 表单模型改 reactive
- 抽出 src/utils/tree.ts(buildTree/filterTree/collectDescendants),组织页复用
- 404 兜底:catch-all 路由 [...all].vue
- 顶栏去掉横向菜单底边框;修复切 Tab 顶部一级菜单高亮不跟随

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 11:06:21 +08:00
liuyun
b5d95c95d8 feat: dictionary & organization management pages
- 字典管理:左右分栏主从布局,20 个字典类型,el-scrollbar 滚动
- 组织管理:树形表格 + 上级组织 tree-select,级联删除下级
- ty-table 新增树形支持(row-key/tree-props/default-expand-all)
- ty-form 新增 tree-select 控件(format/treeData/treeProps)
- JsonSchemaProperty 扩展 tree-select 相关字段

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 10:44:06 +08:00
liuyun
35892486e8 feat: unified dialog manager (showDialog service + ty-dialog host)
- 新增 showDialog({component, props, title,...}) 服务,返回 Promise
- 内容组件 defineExpose({submit}) 校验并返回数据,校验失败对话框不关闭
- <ty-dialog /> 宿主挂 App.vue,统一渲染对话框栈(支持嵌套)
- ty-crud 改用 showDialog 打开表单(移除 ty-form-dialog)
- ty-form 增加 submit();用户管理页重置密码抽成独立组件 + showDialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 10:17:09 +08:00
liuyun
16b512a3f1 feat: config management page
参数配置页:纯键值 CRUD,复用 ty-crud + JSON Schema 零新组件。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 10:06:47 +08:00
liuyun
1df44fd57e feat: complete multi-tab (fixed home, neighbor nav, right-click close)
- 首页 tab 固定不可关闭,tab 持久化(刷新保留)
- 关闭激活 tab 跳转到相邻 tab(而非最后一个)
- 自定义 tab 条 + 右键菜单:关闭其他/左侧/右侧/全部

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 10:04:06 +08:00
liuyun
d88b807638 chore: import full Element Plus CSS, narrow action column, format
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 09:59:43 +08:00
liuyun
d9f1a4d17e feat: search inputs trigger search on Enter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 09:53:26 +08:00
liuyun
1699fee4a8 fix: make inline el-form a flex container so search buttons align right
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 09:50:45 +08:00
liuyun
bc26c0aa93 feat: toolbar justify-between, selection, and card-fill list-scroll
- 搜索按钮置于表单最右(el-form--inline 设为 flex 容器)
- ty-crud 工具条 justify-between:左新增/#toolbar、右批量 #toolbar-right(selectedRows)
- ty-table 支持 selection 多选列 + selection-change
- 卡片铺满剩余空间、列表内滚动(shell 固定视口 + el-table height=100%)
- 用户管理页加批量删除示例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 09:46:39 +08:00
liuyun
1ee3ed28a5 feat: search field width + collapse actions into dropdown
- ty-form 字段支持 width(行内默认 200px、弹窗默认 100%)
- ty-search-form 展开为多行高级搜索
- ty-crud 操作列数据驱动(actions 内置/自定义),超过 collapseCount 折叠为「更多」下拉

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 09:34:39 +08:00
liuyun
104b7678d3 feat: rename ty-form, add search form collapse, use UnoCSS @apply
- ty-schema-form 改名 ty-form
- ty-search-form 支持折叠/展开(collapseCount 默认 2,查询/重置置右)
- ty-table/ty-crud/ty-search-form 样式改用 @apply 工具类

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 09:27:55 +08:00
liuyun
45b22acd6c feat: extract JSON Schema-driven CRUD components, rebuild user page on them
- 新增公共组件:ty-schema-form(JSON Schema 表单)、ty-search-form、ty-table、ty-form-dialog、ty-crud(整页组合)
- 类型 src/types/crud.ts
- 用户管理页改用 ty-crud + JSON Schema + 内联数据(setTimeout 模拟异步),删除 src/api/user.ts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 01:26:11 +08:00
liuyun
5549b2e75c feat: user management page (search/table/pagination/dialog/delete/reset-pwd)
占位数据源 src/api/user.ts(字段对齐后端 User),页面跑通 CRUD 模式,后续接后端只换数据源。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 01:16:08 +08:00
liuyun
246a305df5 feat: hide sidebar when active top-level menu has no children
上下/左右(含 MixBar)布局中,当前选中的顶级菜单无子菜单时不渲染左侧菜单;
Logo 区宽度与折叠按钮随之自适应。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 01:07:49 +08:00
liuyun
537dba9847 fix: align horizontal nav menu height with header to avoid covering bottom border
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:57:07 +08:00
liuyun
c5e044ea42 fix: reduce collapsed sidebar item margin and add popup icon gap
- 折叠时菜单项左右 margin 8px→4px,图标放大到 24px
- 折叠弹出的子菜单(teleport 到 body)用全局样式补图标与文字间距

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:54:20 +08:00
liuyun
1f06594252 fix: enlarge collapsed sidebar icons and make icon component inherit class
- 侧栏折叠时图标放大到 22px(展开时加图标与文字间距)
- 修复 el-menu-item 叶子项 #title 插槽误用
- icon 组件多根节点不自动继承 class,改为 inheritAttrs:false + v-bind="$attrs",使外部 class/size 生效

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:48:33 +08:00
liuyun
82aa944d48 fix: center header logo and fix theme color variants in dark mode
- Logo 区内容居中
- 主题色改为注入 <style> 生成明/暗两套派生变量:暗黑下 light-N 混黑、dark-2 混白,避免侧栏/按钮高亮在暗黑模式下变近白

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:43:31 +08:00
liuyun
99650949c0 style: polish admin layout visuals
- 顶栏改柔和阴影,Logo 区与侧栏连成一体(同宽+右边框)
- 侧栏菜单项圆角、主题色高亮、hover 过渡
- 内容区灰底衬白,footer 加顶边框,多页签激活态配色统一

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:40:11 +08:00
liuyun
66f4a38c98 feat: align header logo with sidebar, slim footer, mixbar only in sidebar layout
- MixBar 仅左右(sidebar)布局可用,horizontal 不再渲染
- 底部 footer 高度收紧
- 顶栏 Logo 区与左侧菜单(或 MixBar+菜单)同宽;侧栏折叠仅显 Logo、展开显 Logo+标题
- 安装 sharp 使 logo.png 构建时优化(-81%)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:35:49 +08:00
liuyun
8a0f510e3a fix: let main content fill height so footer stays at bottom
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:26:00 +08:00
liuyun
86af3c10e1 fix: render header in all layouts (sidebar layout has tools-only header)
左右布局顶栏仅不含导航菜单,折叠/面包屑/全屏/暗黑/设置/用户等工具照常;
固定顶栏对所有布局生效(移除左右禁用)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:23:25 +08:00
liuyun
6ef0b1121c feat: admin layout shell with Element Plus and configurable theme
- 集成 Element Plus(on-demand resolver + 中文 locale + 暗黑 css-vars)
- 布局壳组件(ty-header/ty-mixbar/ty-sidebar/ty-tabs/ty-breadcrumb/ty-footer/ty-setting/ty-setting-panel),中划线 + ty 前缀 + 自动导入,props/emits 解耦
- 5 种布局/导航模式 + 主题色(#2E7FA3) + 暗黑 + 侧栏折叠/宽度 + 固定顶栏/底栏 + 面包屑/多页签/全屏/手风琴
- 默认值集中配置 src/config/layout.ts;菜单字段对齐后端 resource(src/types/resource.ts + src/config/menu.ts)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 00:12:59 +08:00
82 changed files with 7641 additions and 5650 deletions

5
.env
View File

@ -1,5 +0,0 @@
VITE_APP_NAME=demo_default
VITE_NUMBER_DEMO=3000
VITE_BOOLEAN_DEMO=true
VITE_API_BASE_URL=http://localhost:3000/api/v1

View File

@ -1 +0,0 @@
VITE_APP_NAME=demo_dev

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# 后端 API 基地址
VITE_API_BASE_URL=http://localhost:3000/api/v1

6
.gitignore vendored
View File

@ -37,3 +37,9 @@ __screenshots__/
# Vite
*.timestamp-*-*.mjs
# Env
.env
.env.*
!.env.example
stats.html

11
Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build:prod
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

1
auto-imports.d.ts vendored
View File

@ -7,6 +7,7 @@
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const ElMessage: typeof import('element-plus/es').ElMessage
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const asyncComputed: typeof import('@vueuse/core').asyncComputed
const autoResetRef: typeof import('@vueuse/core').autoResetRef

99
components.d.ts vendored
View File

@ -12,15 +12,114 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ChangePasswordForm: typeof import('./src/components/change-password-form.vue')['default']
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']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDialog: typeof import('element-plus/es')['ElDialog']
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']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
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']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
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']
ElTag: typeof import('element-plus/es')['ElTag']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
Icon: typeof import('./src/components/icon/icon.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
TyCrud: typeof import('./src/components/ty-crud.vue')['default']
TyDialog: typeof import('./src/components/ty-dialog.vue')['default']
TyFooter: typeof import('./src/components/ty-footer.vue')['default']
TyForm: typeof import('./src/components/ty-form.vue')['default']
TyHeader: typeof import('./src/components/ty-header.vue')['default']
TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
TySetting: typeof import('./src/components/ty-setting.vue')['default']
TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
TyTable: typeof import('./src/components/ty-table.vue')['default']
TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
// For TSX support
declare global {
const ChangePasswordForm: typeof import('./src/components/change-password-form.vue')['default']
const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
const ElButton: typeof import('element-plus/es')['ElButton']
const ElCard: typeof import('element-plus/es')['ElCard']
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
const ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
const ElDialog: typeof import('element-plus/es')['ElDialog']
const ElDrawer: typeof import('element-plus/es')['ElDrawer']
const ElDropdown: typeof import('element-plus/es')['ElDropdown']
const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
const ElEmpty: typeof import('element-plus/es')['ElEmpty']
const ElForm: typeof import('element-plus/es')['ElForm']
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElInput: typeof import('element-plus/es')['ElInput']
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
const ElLink: typeof import('element-plus/es')['ElLink']
const ElMenu: typeof import('element-plus/es')['ElMenu']
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
const ElOption: typeof import('element-plus/es')['ElOption']
const ElPagination: typeof import('element-plus/es')['ElPagination']
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
const ElSelect: typeof import('element-plus/es')['ElSelect']
const ElSlider: typeof import('element-plus/es')['ElSlider']
const ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
const ElTable: typeof import('element-plus/es')['ElTable']
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
const ElTag: typeof import('element-plus/es')['ElTag']
const ElTree: typeof import('element-plus/es')['ElTree']
const ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
const Icon: typeof import('./src/components/icon/icon.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const TyBreadcrumb: typeof import('./src/components/ty-breadcrumb.vue')['default']
const TyCrud: typeof import('./src/components/ty-crud.vue')['default']
const TyDialog: typeof import('./src/components/ty-dialog.vue')['default']
const TyFooter: typeof import('./src/components/ty-footer.vue')['default']
const TyForm: typeof import('./src/components/ty-form.vue')['default']
const TyHeader: typeof import('./src/components/ty-header.vue')['default']
const TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
const TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
const TySetting: typeof import('./src/components/ty-setting.vue')['default']
const TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
const TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
const TyTable: typeof import('./src/components/ty-table.vue')['default']
const TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
}

View File

@ -2,7 +2,7 @@
"name": "sysmgr-admin",
"version": "0.0.0",
"packageManager": "pnpm@12.10.1",
"description": "程序员优雅哥 agarwood 系列 vue3 模板项目",
"description": "通用系统管理脚手架 - 管理后台前端",
"private": true,
"type": "module",
"scripts": {
@ -22,6 +22,8 @@
"dependencies": {
"@iconify/vue": "^5.0.1",
"@vueuse/core": "^14.3.0",
"axios": "^1.20.0",
"element-plus": "^2.14.7",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.38",
@ -48,6 +50,7 @@
"prettier": "3.8.4",
"rollup-plugin-visualizer": "^7.0.1",
"sass": "^1.101.0",
"sharp": "^0.35.5",
"terser": "^5.48.0",
"typescript": "~6.0.0",
"unocss": "^66.7.2",
@ -68,8 +71,5 @@
"keywords": [
"vue3"
],
"author": {
"name": "程序员优雅哥 youyacoder",
"email": "hero_cloudy@126.com"
}
"license": "MIT"
}

1487
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
<template>
<router-view />
<el-config-provider :locale="zhCn">
<router-view />
<ty-dialog />
</el-config-provider>
</template>
<script setup lang="ts"></script>
<style scoped></style>
<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>

34
src/api/audit-log.ts Normal file
View File

@ -0,0 +1,34 @@
import { http } from '@/api/http'
import type { PageResult } from '@/types/crud'
export interface AuditLog {
id: string
tenantCode: string
userId: string | null
username: string | null
module: string | null
action: string | null
method: string | null
path: string | null
status: string | null
ip: string | null
userAgent: string | null
durationMs: number | null
createdAt: number
}
export interface AuditLogQuery {
page: number
pageSize: number
username?: string
module?: string
status?: string
}
export function listAuditLogs(query: AuditLogQuery, signal?: AbortSignal): Promise<PageResult<AuditLog>> {
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
if (query.username) params.username = query.username
if (query.module) params.module = query.module
if (query.status) params.status = query.status
return http.get<PageResult<AuditLog>>('/audit-logs', { params, signal })
}

46
src/api/auth.ts Normal file
View File

@ -0,0 +1,46 @@
import { http } from '@/api/http'
import type { ResourceNode } from '@/types/resource'
export interface LoginSuccess {
accessToken: string
refreshToken: string
expiresIn: number
}
export interface LoginNeedTenant {
needSelectTenant: true
tenants: { tenantCode: string; tenantName: string }[]
}
export type LoginResult = LoginSuccess | LoginNeedTenant
/** 登录:缺省 tenantCode 时按用户名自动探测;多租户返回 needSelectTenant。 */
export function login(username: string, password: string, tenantCode?: string): Promise<LoginResult> {
return http.post<LoginResult>('/auth/login', { username, password, ...(tenantCode ? { tenantCode } : {}) })
}
/** 当前用户菜单树。 */
export function fetchMenus(): Promise<ResourceNode[]> {
return http.get<ResourceNode[]>('/auth/menus')
}
/** 当前用户权限码与超管标志(供按钮/路由级权限控制)。 */
export function fetchPermissions(): Promise<{ super: boolean; permissions: string[] }> {
return http.get<{ super: boolean; permissions: string[] }>('/auth/permissions')
}
/** 注册账号。 */
export function register(data: {
tenantCode: string
username: string
password: string
email?: string
phone?: string
}): Promise<{ id: string; username: string }> {
return http.post<{ id: string; username: string }>('/auth/register', data)
}
/** 修改本人密码。 */
export function changePassword(oldPassword: string, newPassword: string): Promise<unknown> {
return http.patch('/auth/me/password', { oldPassword, newPassword })
}

View File

@ -1,77 +0,0 @@
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token';
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1';
interface ApiEnvelope<T = unknown> {
code: number;
message: string;
data: T;
}
export class ApiError extends Error {
constructor(
public code: number,
message: string,
) {
super(message);
}
}
async function rawRequest<T>(path: string, options: RequestInit = {}, token?: string): Promise<ApiEnvelope<T>> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
return (await res.json()) as ApiEnvelope<T>;
}
let refreshing: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const body = await rawRequest<{ accessToken: string; refreshToken: string }>('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
if (body.code === 0) {
setTokens(body.data.accessToken, body.data.refreshToken);
return true;
}
} catch {
// 刷新失败,走清空逻辑
}
clearTokens();
return false;
}
/** 统一请求:解析 {code,message,data},成功返回 data;401 自动刷新重试一次。 */
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const attempt = async (retry: boolean): Promise<T> => {
const body = await rawRequest<T>(path, options, getAccessToken() ?? undefined);
if (body.code === 0) return body.data;
if (body.code >= 40100 && body.code < 40200 && retry) {
if (refreshing === null) {
refreshing = tryRefresh().finally(() => {
refreshing = null;
});
}
const ok = await refreshing;
if (ok) return attempt(false);
}
throw new ApiError(body.code, body.message);
};
return attempt(true);
}
export const http = {
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
post: <T>(path: string, data?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(data ?? {}) }),
patch: <T>(path: string, data?: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(data ?? {}) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

54
src/api/config.ts Normal file
View File

@ -0,0 +1,54 @@
import { http } from '@/api/http'
export interface Config {
id: string
tenantCode: string
key: string
value: string | null
remark: string | null
createdAt: number
updatedAt: number
}
export interface ConfigListQuery {
page: number
pageSize: number
key?: string
value?: string
}
export interface ConfigListResult {
list: Config[]
total: number
page: number
pageSize: number
}
export interface ConfigUpsertData {
key?: string
value?: string
remark?: string
}
/** 配置分页列表。 */
export function listConfigs(query: ConfigListQuery, signal?: AbortSignal): Promise<ConfigListResult> {
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
if (query.key) params.key = query.key
if (query.value) params.value = query.value
return http.get<ConfigListResult>('/configs', { params, signal })
}
/** 新增配置。 */
export function createConfig(data: ConfigUpsertData): Promise<Config> {
return http.post<Config>('/configs', data)
}
/** 更新配置(key 不可改)。 */
export function updateConfig(id: string, data: ConfigUpsertData): Promise<Config> {
return http.patch<Config>(`/configs/${id}`, data)
}
/** 删除配置(软删)。 */
export function deleteConfig(id: string): Promise<unknown> {
return http.delete(`/configs/${id}`)
}

98
src/api/dict.ts Normal file
View File

@ -0,0 +1,98 @@
import { http } from '@/api/http'
export interface DictType {
id: string
tenantCode: string
code: string
name: string
status: 'enabled' | 'disabled'
builtIn: boolean
remark: string | null
createdAt: number
}
export interface DictItem {
id: string
tenantCode: string
dictTypeCode: string
label: string
value: string
sort: number
status: 'enabled' | 'disabled'
builtIn: boolean
colorType: string | null
createdAt: number
}
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}
// ---- 聚合查询(GET /dicts) ----
export interface DictOption {
label: string
value: string
colorType: string | null
sort: number
}
export interface DictEntry {
code: string
name: string
items: DictOption[]
}
/** 一次拉取全部启用字典(含字典项),供前端登录后缓存。 */
export function listDicts(signal?: AbortSignal): Promise<{ list: DictEntry[] }> {
return http.get<{ list: DictEntry[] }>('/dicts', { signal })
}
// ---- 字典类型 ----
export function listDictTypes(page = 1, pageSize = 1000, signal?: AbortSignal): Promise<PageResult<DictType>> {
return http.get<PageResult<DictType>>('/dict-types', { params: { page, pageSize }, signal })
}
export function createDictType(data: { code: string; name: string; remark?: string }): Promise<DictType> {
return http.post<DictType>('/dict-types', data)
}
export function updateDictType(id: string, data: { name?: string; status?: string; remark?: string }): Promise<DictType> {
return http.patch<DictType>(`/dict-types/${id}`, data)
}
export function deleteDictType(id: string): Promise<unknown> {
return http.delete(`/dict-types/${id}`)
}
// ---- 字典项 ----
/** 某字典类型下的全部字典项(后端不分页,按 sort 升序返回)。 */
export function listDictItems(dictTypeCode: string, signal?: AbortSignal): Promise<DictItem[]> {
return http.get<DictItem[]>('/dict-items', { params: { dictTypeCode }, signal })
}
export function createDictItem(data: {
dictTypeCode: string
label: string
value: string
sort?: number
colorType?: string
}): Promise<DictItem> {
return http.post<DictItem>('/dict-items', data)
}
export function updateDictItem(
id: string,
data: { label?: string; value?: string; sort?: number; status?: string; colorType?: string },
): Promise<DictItem> {
return http.patch<DictItem>(`/dict-items/${id}`, data)
}
export function deleteDictItem(id: string): Promise<unknown> {
return http.delete(`/dict-items/${id}`)
}

138
src/api/http.ts Normal file
View File

@ -0,0 +1,138 @@
import axios, { type AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token'
/** 后端统一响应包裹:{ code, message, data },code=0 成功。 */
interface ApiEnvelope<T = unknown> {
code: number
message: string
data: T
}
/** 业务错误(code 为 HTTP 状态码 * 100,如 40100/40300/50000)。 */
export class ApiError extends Error {
constructor(
public code: number,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
/** 会话过期回调(token 刷新失败时触发),由应用启动时注册。 */
let onAuthExpired: (() => void) | null = null
export function setAuthExpiredHandler(handler: () => void): void {
onAuthExpired = handler
}
interface HttpClientOptions {
baseURL?: string
timeout?: number
}
/**
* 面向对象的 Axios 封装:
* - 请求拦截器:注入 Authorization 头
* - 响应拦截器:拆 { code, message, data },code=0 返回 data;401 自动刷新重放;其余抛 ApiError
*/
export class HttpClient {
private instance: AxiosInstance
/** 无拦截器的裸实例,供刷新令牌等内部请求使用(避免递归拦截)。 */
private rawInstance: AxiosInstance
private refreshing: Promise<string> | null = null
constructor(options: HttpClientOptions = {}) {
const baseURL = options.baseURL ?? import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
const timeout = options.timeout ?? 10000
this.instance = axios.create({ baseURL, timeout })
this.rawInstance = axios.create({ baseURL, timeout })
this.setupInterceptors()
}
private setupInterceptors(): void {
this.instance.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
this.instance.interceptors.response.use(
(response) => {
const body = response.data as ApiEnvelope
if (body.code === 0) return body.data as AxiosResponse
return Promise.reject(new ApiError(body.code, body.message))
},
async (error: AxiosError) => {
const config = error.config as (AxiosRequestConfig & { _retry?: boolean }) | undefined
const url = config?.url ?? ''
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/refresh')
// 401 自动刷新并重放一次(登录/刷新接口自身 401 不重放,_retry 防死循环)
if (error.response?.status === 401 && config && !config._retry && !isAuthEndpoint) {
config._retry = true
try {
const token = await this.refresh()
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${token}`
return this.instance.request(config)
} catch {
clearTokens()
onAuthExpired?.()
}
}
const body = error.response?.data as ApiEnvelope | undefined
return Promise.reject(new ApiError(body?.code ?? -1, body?.message ?? error.message))
},
)
}
/** 刷新令牌(并发去重),返回新 accessToken。 */
private refresh(): Promise<string> {
if (!this.refreshing) {
this.refreshing = this.doRefresh().finally(() => {
this.refreshing = null
})
}
return this.refreshing
}
private async doRefresh(): Promise<string> {
const refreshToken = getRefreshToken()
if (!refreshToken) throw new ApiError(40100, '未登录')
const { data } = await this.rawInstance.post<ApiEnvelope<{ accessToken: string; refreshToken: string }>>(
'/auth/refresh',
{ refreshToken },
)
if (data.code !== 0) throw new ApiError(data.code, data.message)
setTokens(data.data.accessToken, data.data.refreshToken)
return data.data.accessToken
}
request<T>(config: AxiosRequestConfig): Promise<T> {
return this.instance.request(config) as Promise<T>
}
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'GET', url })
}
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'POST', url, data })
}
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'PUT', url, data })
}
patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'PATCH', url, data })
}
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'DELETE', url })
}
}
/** 全局单例,业务直接使用 http.get/post/... */
export const http = new HttpClient()

27
src/api/login-log.ts Normal file
View File

@ -0,0 +1,27 @@
import { http } from '@/api/http'
import type { PageResult } from '@/types/crud'
export interface LoginLog {
id: string
tenantCode: string
username: string | null
ip: string | null
userAgent: string | null
status: string
message: string | null
createdAt: number
}
export interface LoginLogQuery {
page: number
pageSize: number
username?: string
status?: string
}
export function listLoginLogs(query: LoginLogQuery, signal?: AbortSignal): Promise<PageResult<LoginLog>> {
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
if (query.username) params.username = query.username
if (query.status) params.status = query.status
return http.get<PageResult<LoginLog>>('/login-logs', { params, signal })
}

39
src/api/org.ts Normal file
View File

@ -0,0 +1,39 @@
import { http } from '@/api/http'
export interface Org {
id: string
tenantCode: string
parentCode: string | null
code: string
name: string
leader: string | null
sort: number
status: 'enabled' | 'disabled'
createdAt: number
}
/** 组织列表(后端按 sort 升序返回普通数组,前端自行组树)。 */
export function listOrgs(signal?: AbortSignal): Promise<Org[]> {
return http.get<Org[]>('/orgs', { signal })
}
export function createOrg(data: {
code: string
name: string
parentCode?: string | null
leader?: string
sort?: number
}): Promise<Org> {
return http.post<Org>('/orgs', data)
}
export function updateOrg(
id: string,
data: { name?: string; parentCode?: string | null; leader?: string; sort?: number; status?: string },
): Promise<Org> {
return http.patch<Org>(`/orgs/${id}`, data)
}
export function deleteOrg(id: string): Promise<unknown> {
return http.delete(`/orgs/${id}`)
}

51
src/api/resource.ts Normal file
View File

@ -0,0 +1,51 @@
import { http } from '@/api/http'
export type ResourceType = 'menu' | 'button' | 'api'
export interface Resource {
id: string
tenantCode: string
parentCode: string | null
type: ResourceType
code: string
name: string
path: string | null
component: string | null
method: string | null
apiPath: string | null
icon: string | null
sort: number
status: 'enabled' | 'disabled'
createdAt: number
}
export interface ResourceUpsertData {
type?: ResourceType
code?: string
name?: string
parentCode?: string | null
path?: string
component?: string
method?: string
apiPath?: string
icon?: string
sort?: number
status?: string
}
/** 资源列表(后端按 sort 升序返回普通数组,前端自行组树)。 */
export function listResources(signal?: AbortSignal): Promise<Resource[]> {
return http.get<Resource[]>('/resources', { signal })
}
export function createResource(data: ResourceUpsertData): Promise<Resource> {
return http.post<Resource>('/resources', data)
}
export function updateResource(id: string, data: ResourceUpsertData): Promise<Resource> {
return http.patch<Resource>(`/resources/${id}`, data)
}
export function deleteResource(id: string): Promise<unknown> {
return http.delete(`/resources/${id}`)
}

62
src/api/role.ts Normal file
View File

@ -0,0 +1,62 @@
import { http } from '@/api/http'
export type DataScope = 'all' | 'dept' | 'dept_and_child' | 'self' | 'custom'
export interface Role {
id: string
tenantCode: string
code: string
name: string
dataScope: DataScope
status: 'enabled' | 'disabled'
remark: string | null
createdAt: number
}
export interface RoleListQuery {
page: number
pageSize: number
name?: string
code?: string
status?: string
}
export interface RoleListResult {
list: Role[]
total: number
page: number
pageSize: number
}
export function listRoles(query: RoleListQuery, signal?: AbortSignal): Promise<RoleListResult> {
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
if (query.name) params.name = query.name
if (query.code) params.code = query.code
if (query.status) params.status = query.status
return http.get<RoleListResult>('/roles', { params, signal })
}
export function createRole(data: { code: string; name: string; dataScope?: DataScope; remark?: string }): Promise<Role> {
return http.post<Role>('/roles', data)
}
export function updateRole(
id: string,
data: { name?: string; dataScope?: DataScope; status?: string; remark?: string },
): Promise<Role> {
return http.patch<Role>(`/roles/${id}`, data)
}
export function deleteRole(id: string): Promise<unknown> {
return http.delete(`/roles/${id}`)
}
/** 角色已分配的资源 code 列表(注意:URL 的 :id 是 roleCode 而非 UUID)。 */
export function listAssignedResources(roleCode: string, signal?: AbortSignal): Promise<string[]> {
return http.get<string[]>(`/roles/${roleCode}/resources`, { signal })
}
/** 分配资源(注意:URL 的 :id 是 roleCode 而非 UUID)。 */
export function assignResources(roleCode: string, resourceCodes: string[]): Promise<unknown> {
return http.put(`/roles/${roleCode}/resources`, { resourceCodes })
}

45
src/api/tenant.ts Normal file
View File

@ -0,0 +1,45 @@
import { http } from '@/api/http'
export interface Tenant {
id: string
code: string
name: string
status: 'enabled' | 'disabled'
remark: string | null
createdAt: number
}
export interface TenantListQuery {
page: number
pageSize: number
name?: string
code?: string
status?: string
}
export interface TenantListResult {
list: Tenant[]
total: number
page: number
pageSize: number
}
export function listTenants(query: TenantListQuery, signal?: AbortSignal): Promise<TenantListResult> {
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
if (query.name) params.name = query.name
if (query.code) params.code = query.code
if (query.status) params.status = query.status
return http.get<TenantListResult>('/tenants', { params, signal })
}
export function createTenant(data: { code: string; name: string; status?: string; remark?: string }): Promise<Tenant> {
return http.post<Tenant>('/tenants', data)
}
export function updateTenant(id: string, data: { name?: string; status?: string; remark?: string }): Promise<Tenant> {
return http.patch<Tenant>(`/tenants/${id}`, data)
}
export function deleteTenant(id: string): Promise<unknown> {
return http.delete(`/tenants/${id}`)
}

83
src/api/user.ts Normal file
View File

@ -0,0 +1,83 @@
import { http } from '@/api/http'
export type UserStatus = 'enabled' | 'disabled' | 'locked'
export interface User {
id: string
tenantCode: string
username: string
nickname: string | null
email: string | null
phone: string | null
avatar: string | null
status: UserStatus
orgCode: string | null
createdAt: number
updatedAt: number
}
export interface UserListQuery {
page: number
pageSize: number
username?: string
nickname?: string
phone?: string
status?: UserStatus | ''
}
export interface UserListResult {
list: User[]
total: number
page: number
pageSize: number
}
export interface UserUpsertData {
username?: string
password?: string
nickname?: string
email?: string
phone?: string
orgCode?: string | null
status?: UserStatus
}
/** 用户分页列表。 */
export function listUsers(query: UserListQuery, signal?: AbortSignal): Promise<UserListResult> {
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
if (query.username) params.username = query.username
if (query.nickname) params.nickname = query.nickname
if (query.phone) params.phone = query.phone
if (query.status) params.status = query.status
return http.get<UserListResult>('/users', { params, signal })
}
/** 新增用户。 */
export function createUser(data: UserUpsertData): Promise<User> {
return http.post<User>('/users', data)
}
/** 更新用户(username 不可改)。 */
export function updateUser(id: string, data: UserUpsertData): Promise<User> {
return http.patch<User>(`/users/${id}`, data)
}
/** 删除用户(软删)。 */
export function deleteUser(id: string): Promise<unknown> {
return http.delete(`/users/${id}`)
}
/** 重置密码。 */
export function resetPassword(id: string, password: string): Promise<unknown> {
return http.patch(`/users/${id}/password`, { password })
}
/** 用户已分配的角色 code 列表。 */
export function listUserRoles(userId: string, signal?: AbortSignal): Promise<string[]> {
return http.get<string[]>(`/users/${userId}/roles`, { signal })
}
/** 分配角色(整体替换)。 */
export function assignUserRoles(userId: string, roleCodes: string[]): Promise<unknown> {
return http.put(`/users/${userId}/roles`, { roleCodes })
}

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@ -1,3 +1,4 @@
// 导入变量文件
@use "element-plus/dist/index.css";
@use "./settings";
@use "./base";

View File

@ -0,0 +1,48 @@
<template>
<el-form ref="formRef" :model="model" :rules="rules" label-width="80px">
<el-form-item label="原密码" prop="oldPassword">
<el-input v-model="model.oldPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="model.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="model.confirmPassword" type="password" show-password />
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'ChangePasswordForm' })
const formRef = ref<FormInstance>()
const model = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const rules: FormRules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value !== model.newPassword) callback(new Error('两次输入的密码不一致'))
else callback()
},
trigger: 'blur',
},
],
}
async function submit(): Promise<{ oldPassword: string; newPassword: string }> {
await formRef.value?.validate()
return { oldPassword: model.oldPassword, newPassword: model.newPassword }
}
defineExpose({ submit })
</script>

View File

@ -5,24 +5,24 @@
@time: 2026/2/11 15:45
-->
<template>
<div v-if="type === 'uno'" :class="icon" />
<div v-if="type === 'uno'" v-bind="$attrs" :class="icon" />
<iconify-icon v-else-if="type === 'iconify'" :icon="icon" class="icon" :aria-hidden="false" />
<iconify-icon v-else-if="type === 'iconify'" v-bind="$attrs" :icon="icon" class="icon" :aria-hidden="false" />
<svg v-else-if="type === 'svg'" class="svg">
<svg v-else-if="type === 'svg'" v-bind="$attrs" class="svg">
<use :href="svgSymbolId" fill="currentColor" />
</svg>
<i v-else-if="type === 'iconfont'" :class="iconfontClassName" />
<i v-else-if="type === 'iconfont'" v-bind="$attrs" :class="iconfontClassName" />
<span v-else>Unsupported type: {{ type }}</span>
<span v-else v-bind="$attrs">Unsupported type: {{ type }}</span>
</template>
<script setup lang="ts">
import { onBeforeMount, computed } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
defineOptions({ name: 'Icon' })
defineOptions({ name: 'Icon', inheritAttrs: false })
const props = withDefaults(
defineProps<{
@ -68,8 +68,6 @@ const iconfontClassName = computed(() => `${props.fontFamily} ${props.prefix}-${
@apply inline-block align-mid text-xl;
}
.svg {
@apply inline-block align-mid;
width: 1rem;
height: 1rem;
@apply inline-block align-mid h-4 w-4;
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<el-breadcrumb class="ty-breadcrumb" separator="/">
<el-breadcrumb-item v-for="(item, index) in items" :key="index">
<span :class="{ 'is-link': item.path && index < items.length - 1 }">{{ item.title }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup lang="ts">
defineOptions({ name: 'TyBreadcrumb' })
export interface BreadcrumbItem {
title: string
path?: string
}
defineProps<{
/** 面包屑项(按层级从高到低)。 */
items: BreadcrumbItem[]
}>()
</script>
<style scoped lang="scss">
.ty-breadcrumb {
font-size: var(--wm-font-size-sm);
}
</style>

309
src/components/ty-crud.vue Normal file
View File

@ -0,0 +1,309 @@
<template>
<div class="ty-crud">
<el-card v-if="searchSchema" class="ty-crud__search" shadow="never">
<ty-search-form
:schema="searchSchema"
:model-value="query"
@search="handleSearch"
@reset="handleReset"
/>
</el-card>
<el-card class="ty-crud__table" shadow="never">
<div class="ty-crud__toolbar">
<div class="ty-crud__toolbar-left">
<el-button v-if="!createPermission || permissionStore.has(createPermission)" type="primary" @click="openCreate">
新增{{ entityName }}
</el-button>
<slot name="toolbar" />
</div>
<div class="ty-crud__toolbar-right">
<slot name="toolbar-right" :selected-rows="selectedRows" />
</div>
</div>
<div class="ty-crud__table-body">
<ty-table
:columns="columns"
:data="list"
:loading="loading"
:selection="selection"
:total="total"
v-model:page="page"
v-model:page-size="pageSize"
:actions-width="actionsWidth"
@selection-change="onSelectionChange"
>
<template #actions="{ row }">
<el-button
v-for="a in displayActions(row)"
:key="a.label"
link
:type="a.type"
@click="a.onClick(row)"
>
{{ a.label }}
</el-button>
<el-dropdown
v-if="overflowActions(row).length > 0"
trigger="click"
@command="handleCommand($event, row)"
>
<el-button link type="primary">
更多
<icon type="iconify" icon="mdi:chevron-down" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="a in overflowActions(row)" :key="a.label" :command="a">
{{ a.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</ty-table>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type {
CrudAction,
CrudActionItem,
JsonSchema,
PageQuery,
PageResult,
TableColumn,
} from '@/types/crud'
import TyForm from '@/components/ty-form.vue'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import { usePermissionStore } from '@/stores/modules/permission'
defineOptions({ name: 'TyCrud' })
const permissionStore = usePermissionStore()
const props = withDefaults(
defineProps<{
/** 实体名(用于标题与删除确认,如「用户」)。 */
entityName: string
/** 表格列配置。 */
columns: TableColumn[]
/** 表单 JSON Schema。 */
formSchema: JsonSchema
/** 搜索 JSON Schema(可选)。 */
searchSchema?: JsonSchema
/** 数据加载:分页查询(signal 用于取消请求)。 */
list: (query: PageQuery, signal?: AbortSignal) => Promise<PageResult<Record<string, any>>>
/** 新增。 */
create: (data: Record<string, any>) => Promise<unknown>
/** 更新。 */
update: (id: string, data: Record<string, any>) => Promise<unknown>
/** 删除。 */
remove: (id: string) => Promise<unknown>
/** 主键字段名。 */
idKey?: string
/** 删除确认展示的字段名。 */
nameKey?: string
/** 编辑模式下强制只读的字段。 */
editReadonlyKeys?: string[]
/** 编辑模式下按行计算只读字段(优先于 editReadonlyKeys)。 */
getEditReadonlyKeys?: (row: Record<string, any>) => string[]
/** 操作列动作(内置 'edit'/'delete' 或自定义动作)。 */
actions?: CrudAction[]
/** 操作列按钮超过该数量时折叠为「更多」。 */
collapseCount?: number
/** 是否启用多选列(配合 #toolbar-right 做批量操作)。 */
selection?: boolean
/** 新增按钮所需权限码(无该权限且非超管时隐藏新增按钮)。 */
createPermission?: string
actionsWidth?: number | string
}>(),
{
searchSchema: undefined,
idKey: 'id',
nameKey: 'name',
editReadonlyKeys: () => [],
actions: () => ['edit', 'delete'],
collapseCount: 2,
selection: false,
actionsWidth: 140,
},
)
const list = ref<Record<string, any>[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const selectedRows = ref<Record<string, any>[]>([])
const query = reactive<Record<string, any>>(buildModel(props.searchSchema))
function buildModel(schema?: JsonSchema): Record<string, any> {
const model: Record<string, any> = reactive({})
if (!schema) return model
for (const [key, prop] of Object.entries(schema.properties)) {
if (prop.default !== undefined) model[key] = prop.default
else if (prop.enum?.length) model[key] = prop.enum[0]
else if (prop.type === 'boolean') model[key] = false
else model[key] = ''
}
return model
}
const { loading, run: fetchList } = useRequest(
(signal: AbortSignal) => props.list({ page: page.value, pageSize: pageSize.value, ...query }, signal),
{
manual: true,
onSuccess: (result) => {
list.value = result.list
total.value = result.total
},
},
)
// 分页变化时重新请求
watch([page, pageSize], () => {
fetchList()
})
function handleSearch(): void {
resetAndFetch()
}
function handleReset(): void {
resetAndFetch()
}
/** 重置到第一页并查询:page 已是 1 时 watch 不触发,需手动查询,避免重复请求。 */
function resetAndFetch(): void {
if (page.value === 1) {
fetchList()
} else {
page.value = 1
}
}
async function openCreate(): Promise<void> {
const model = buildModel(props.formSchema)
const data = await showDialog({
component: TyForm,
props: { schema: props.formSchema, modelValue: model, mode: 'create' },
title: `新增${props.entityName}`,
})
if (data == null) return
await props.create(data)
ElMessage.success('新增成功')
fetchList()
}
async function openEdit(row: Record<string, any>): Promise<void> {
const model = buildModel(props.formSchema)
for (const key of Object.keys(props.formSchema.properties)) {
model[key] = row[key] ?? model[key]
}
const data = await showDialog({
component: TyForm,
props: {
schema: props.formSchema,
modelValue: model,
mode: 'edit',
readonlyKeys: props.getEditReadonlyKeys ? props.getEditReadonlyKeys(row) : props.editReadonlyKeys,
},
title: `编辑${props.entityName}`,
})
if (data == null) return
await props.update(String(row[props.idKey]), data)
ElMessage.success('更新成功')
fetchList()
}
async function handleDelete(row: Record<string, any>): Promise<void> {
const name = row[props.nameKey] ?? ''
try {
await ElMessageBox.confirm(`确认删除${props.entityName}「${name}」?`, '提示', {
type: 'warning',
})
} catch {
return // 用户取消
}
await props.remove(String(row[props.idKey]))
ElMessage.success('删除成功')
fetchList()
}
const resolvedActions = computed<CrudActionItem[]>(() =>
props.actions.map((a): CrudActionItem => {
if (a === 'edit') return { label: '编辑', type: 'primary', onClick: (row) => openEdit(row) }
if (a === 'delete')
return { label: '删除', type: 'danger', onClick: (row) => handleDelete(row) }
return a
}),
)
function visibleActions(row: Record<string, any>): CrudActionItem[] {
return resolvedActions.value.filter((a) => {
if (a.show && !a.show(row)) return false
if (a.permission && !permissionStore.has(a.permission)) return false
return true
})
}
function displayActions(row: Record<string, any>): CrudActionItem[] {
const visible = visibleActions(row)
return visible.length > props.collapseCount ? visible.slice(0, 1) : visible
}
function overflowActions(row: Record<string, any>): CrudActionItem[] {
const visible = visibleActions(row)
return visible.length > props.collapseCount ? visible.slice(1) : []
}
function handleCommand(action: CrudActionItem, row: Record<string, any>): void {
action.onClick(row)
}
function onSelectionChange(rows: Record<string, any>[]): void {
selectedRows.value = rows
}
defineExpose({ reload: fetchList })
onMounted(fetchList)
</script>
<style scoped lang="scss">
.ty-crud {
@apply flex h-full flex-col;
}
.ty-crud__search {
@apply mb-md flex-shrink-0;
}
.ty-crud__table {
@apply flex min-h-0 flex-1 flex-col;
:deep(.el-card__body) {
@apply flex min-h-0 flex-1 flex-col;
}
}
.ty-crud__toolbar {
@apply mb-md flex flex-shrink-0 items-center justify-between;
}
.ty-crud__toolbar-left,
.ty-crud__toolbar-right {
@apply flex items-center gap-2;
}
.ty-crud__table-body {
@apply min-h-0 flex-1;
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<el-dialog
v-for="dialog in dialogs"
:key="dialog.id"
:model-value="true"
:title="dialog.options.title"
:width="dialog.options.width ?? '480px'"
:close-on-click-modal="false"
:close-on-press-escape="false"
@close="handleClose(dialog)"
>
<component
:is="dialog.options.component"
v-bind="dialog.options.props ?? {}"
:ref="setRef(dialog.id)"
/>
<template v-if="dialog.options.showFooter !== false" #footer>
<el-button @click="handleCancel(dialog)">{{ dialog.options.cancelText ?? '取消' }}</el-button>
<el-button type="primary" @click="handleConfirm(dialog)">{{ dialog.options.confirmText ?? '确定' }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { closeDialog, dialogs, type DialogInstance } from '@/utils/dialog'
defineOptions({ name: 'TyDialog' })
const componentRefs = reactive<Record<number, any>>({})
function setRef(id: number): (el: unknown) => void {
return (el) => {
if (el) componentRefs[id] = el
else delete componentRefs[id]
}
}
async function handleConfirm(dialog: DialogInstance): Promise<void> {
const ref = componentRefs[dialog.id]
let data: any
if (ref?.submit) {
try {
data = await ref.submit()
} catch {
// 校验失败,保持对话框打开
return
}
}
if (dialog.options.onConfirm) await dialog.options.onConfirm(data)
closeDialog(dialog.id, data)
}
function handleCancel(dialog: DialogInstance): void {
closeDialog(dialog.id, null)
}
function handleClose(dialog: DialogInstance): void {
closeDialog(dialog.id, null)
}
</script>

View File

@ -0,0 +1,34 @@
<template>
<footer class="ty-footer" :class="{ 'is-fixed': fixed }">
<span>{{ copyright }}</span>
</footer>
</template>
<script setup lang="ts">
defineOptions({ name: 'TyFooter' })
withDefaults(
defineProps<{
/** 版权文案。 */
copyright?: string
/** 是否固定在视口底部。 */
fixed?: boolean
}>(),
{
copyright: '云南投云科技有限公司',
fixed: false,
},
)
</script>
<style scoped lang="scss">
.ty-footer {
@apply flex items-center justify-center px-4 py-1.5 text-xs text-text-secondary bg-bg-base border-t border-solid;
line-height: 1.4;
border-color: var(--wm-border-color-light);
}
.ty-footer.is-fixed {
@apply sticky bottom-0 z-10;
}
</style>

206
src/components/ty-form.vue Normal file
View File

@ -0,0 +1,206 @@
<template>
<el-form ref="formRef" :inline="inline" :model="modelValue" :rules="rules" :label-width="inline ? 'auto' : '90px'">
<el-form-item
v-for="(prop, key) in visibleProperties"
:key="key"
:label="prop.title ?? key"
:prop="key"
>
<el-select
v-if="prop.enum || prop.dictCode"
v-model="modelValue[key]"
:placeholder="prop.placeholder ?? '请选择'"
:clearable="!isRequired(key)"
:disabled="isReadonly(key, prop)"
:style="widgetStyle(prop)"
>
<el-option
v-for="opt in selectOptions(prop)"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-tree-select
v-else-if="prop.format === 'tree-select'"
v-model="modelValue[key]"
:data="prop.treeData ?? []"
:node-key="prop.treeProps?.value ?? 'value'"
:props="{
label: prop.treeProps?.label ?? 'label',
children: prop.treeProps?.children ?? 'children',
}"
check-strictly
:placeholder="prop.placeholder ?? '请选择'"
:clearable="!isRequired(key)"
:disabled="isReadonly(key, prop)"
:style="widgetStyle(prop)"
/>
<el-switch v-else-if="prop.type === 'boolean'" v-model="modelValue[key]" :disabled="isReadonly(key, prop)" />
<el-input-number
v-else-if="prop.type === 'integer' || prop.type === 'number'"
v-model="modelValue[key]"
:min="prop.minimum"
:max="prop.maximum"
:disabled="isReadonly(key, prop)"
:style="widgetStyle(prop)"
/>
<el-input
v-else-if="prop.format === 'password'"
v-model="modelValue[key]"
type="password"
show-password
:placeholder="prop.placeholder"
:disabled="isReadonly(key, prop)"
:style="widgetStyle(prop)"
/>
<el-input
v-else-if="prop.format === 'textarea'"
v-model="modelValue[key]"
type="textarea"
:placeholder="prop.placeholder"
:disabled="isReadonly(key, prop)"
:style="widgetStyle(prop)"
/>
<el-input
v-else
v-model="modelValue[key]"
:placeholder="prop.placeholder"
:disabled="isReadonly(key, prop)"
:style="widgetStyle(prop)"
@keyup.enter="onEnter"
/>
</el-form-item>
<slot />
</el-form>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { JsonSchema, JsonSchemaProperty } from '@/types/crud'
import { useDictStore } from '@/stores/modules/dict'
defineOptions({ name: 'TyForm' })
const dictStore = useDictStore()
const props = withDefaults(
defineProps<{
/** 表单 JSON Schema。 */
schema: JsonSchema
/** 表单数据(就地绑定,会直接修改传入对象)。 */
modelValue: Record<string, any>
/** 是否行内布局(用于搜索)。 */
inline?: boolean
/** 表单模式(create/edit),用于 showOn 字段过滤。 */
mode?: 'create' | 'edit'
/** 输入框回车触发 submit 事件。 */
enterToSubmit?: boolean
/** 强制只读的字段名(如编辑模式下的 username)。 */
readonlyKeys?: string[]
}>(),
{
inline: false,
mode: undefined,
enterToSubmit: false,
readonlyKeys: () => [],
},
)
const emit = defineEmits<{
(e: 'submit'): void
}>()
const formRef = ref<FormInstance>()
function onEnter(): void {
if (props.enterToSubmit) emit('submit')
}
/** 按 mode 过滤 showOn 字段,并应用 when 条件(依赖字段值联动显隐)。 */
const visibleProperties = computed(() => {
const result: Record<string, JsonSchemaProperty> = {}
for (const [key, prop] of Object.entries(props.schema.properties)) {
if (props.mode && prop.showOn && !prop.showOn.includes(props.mode)) continue
if (prop.when && !matchesWhen(props.modelValue, prop.when)) continue
result[key] = prop
}
return result
})
/** when 条件匹配:依赖字段值等于 equals 或属于 in。 */
function matchesWhen(model: Record<string, any>, when: { field: string; equals?: unknown; in?: unknown[] }): boolean {
const value = model[when.field]
if (when.equals !== undefined) return value === when.equals
if (when.in) return when.in.includes(value)
return true
}
const rules = computed<FormRules>(() => {
const result: FormRules = {}
for (const [key, prop] of Object.entries(props.schema.properties)) {
const itemRules: Record<string, unknown>[] = []
if (props.schema.required?.includes(key)) {
itemRules.push({ required: true, message: `${prop.title ?? key}不能为空`, trigger: 'blur' })
}
if (prop.minLength) {
itemRules.push({ min: prop.minLength, message: `长度不能少于 ${prop.minLength} 位`, trigger: 'blur' })
}
if (prop.format === 'email') {
itemRules.push({ type: 'email', message: '邮箱格式不正确', trigger: 'blur' })
}
if (itemRules.length) result[key] = itemRules
}
return result
})
function isRequired(key: string): boolean {
return props.schema.required?.includes(key) ?? false
}
function isReadonly(key: string, prop: { readOnly?: boolean }): boolean {
return prop.readOnly === true || props.readonlyKeys.includes(key)
}
/** 下拉选项:dictCode 优先,其次 enum/enumNames。 */
function selectOptions(prop: JsonSchemaProperty): { value: string | number | boolean; label: string }[] {
if (prop.dictCode) {
return (dictStore.dicts[prop.dictCode] ?? []).map((i) => ({ value: i.value, label: i.label }))
}
if (prop.enum) {
return prop.enum.map((v, i) => ({ value: v, label: prop.enumNames?.[i] ?? String(v) }))
}
return []
}
function widgetStyle(prop: JsonSchemaProperty): Record<string, string> {
return { width: prop.width ?? (props.inline ? '200px' : '100%') }
}
async function submit(): Promise<Record<string, any>> {
await formRef.value?.validate()
return { ...props.modelValue }
}
defineExpose({
submit,
validate: () => formRef.value?.validate(),
resetFields: () => formRef.value?.resetFields(),
clearValidate: () => formRef.value?.clearValidate(),
})
</script>
<style scoped lang="scss">
/* inline 模式下让 el-form 成为 flex 容器,使「查询/重置」按钮可通过 ml-auto 贴右 */
.el-form--inline {
@apply flex flex-wrap items-center;
}
</style>

View File

@ -0,0 +1,241 @@
<template>
<header class="ty-header" :class="{ 'is-fixed': fixed }">
<div class="ty-header__left">
<div class="ty-header__logo" :style="logoWidth != null ? { width: `${logoWidth}px` } : undefined">
<img v-if="logo" class="ty-header__logo-img" :src="logo" alt="logo" />
<span v-if="showTitle" class="ty-header__title">{{ title }}</span>
</div>
<button v-if="hasSidebar" class="ty-header__action" type="button" title="折叠/展开菜单" @click="emit('toggle-collapse')">
<icon type="iconify" :icon="collapsed ? 'mdi:menu-open' : 'mdi:menu'" />
</button>
<!-- 顶部导航:top 显示全树;horizontal 显示一级 -->
<el-menu
v-if="layout !== 'sidebar'"
class="ty-header__nav"
mode="horizontal"
:ellipsis="false"
:default-active="activeIndex"
@select="onNavSelect"
>
<template v-for="item in menus" :key="item.code">
<el-sub-menu v-if="layout === 'top' && item.children?.length" :index="item.code">
<template #title>
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" class="menu-icon" />
<span>{{ item.name }}</span>
</template>
<el-menu-item v-for="child in item.children" :key="child.code" :index="child.path ?? child.code">
{{ child.name }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="item.code">
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" class="menu-icon" />
<span>{{ item.name }}</span>
</el-menu-item>
</template>
</el-menu>
</div>
<ty-breadcrumb v-if="showBreadcrumb" :items="breadcrumbs" class="ty-header__breadcrumb" />
<div class="ty-header__right">
<button v-if="showFullscreen" class="ty-header__action" type="button" title="全屏" @click="emit('toggle-fullscreen')">
<icon type="iconify" :icon="isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'" />
</button>
<button class="ty-header__action" type="button" title="暗黑模式" @click="emit('toggle-dark')">
<icon type="iconify" :icon="isDark ? 'mdi:white-balance-sunny' : 'mdi:weather-night'" />
</button>
<button class="ty-header__action" type="button" title="布局设置" @click="emit('open-settings')">
<icon type="iconify" icon="mdi:cog-outline" />
</button>
<el-dropdown trigger="click" @command="onUserCommand">
<span class="ty-header__user">
<icon type="iconify" icon="mdi:account-circle" />
<span>{{ username }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="change-password">修改密码</el-dropdown-item>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ResourceNode } from '@/types/resource'
import type { LayoutType } from '@/config/layout'
import type { BreadcrumbItem } from './ty-breadcrumb.vue'
defineOptions({ name: 'TyHeader' })
const props = withDefaults(
defineProps<{
/** 应用标题。 */
title?: string
/** Logo 图片地址。 */
logo?: string
/** 是否显示标题文字(侧栏折叠或纯图标时仅显 Logo)。 */
showTitle?: boolean
/** Logo 区宽度(px,与左侧菜单/菜单+MixBar 对齐);null 表示自适应。 */
logoWidth?: number | null
/** 是否有侧边栏(决定是否显示折叠按钮)。 */
hasSidebar?: boolean
/** 侧边栏是否折叠。 */
collapsed?: boolean
/** 顶栏是否固定。 */
fixed?: boolean
/** 是否显示面包屑。 */
showBreadcrumb?: boolean
/** 面包屑项。 */
breadcrumbs?: BreadcrumbItem[]
/** 是否显示全屏按钮。 */
showFullscreen?: boolean
/** 当前是否全屏。 */
isFullscreen?: boolean
/** 当前是否暗黑。 */
isDark?: boolean
/** 布局类型(决定顶部导航渲染方式)。 */
layout?: LayoutType
/** 顶部导航菜单。 */
menus?: ResourceNode[]
/** 当前激活路径。 */
activePath?: string
/** 当前用户名。 */
username?: string
}>(),
{
title: 'sysmgr',
logo: '',
showTitle: true,
logoWidth: null,
hasSidebar: true,
collapsed: false,
fixed: true,
showBreadcrumb: true,
breadcrumbs: () => [],
showFullscreen: true,
isFullscreen: false,
isDark: false,
layout: 'horizontal',
menus: () => [],
activePath: '',
username: '',
},
)
/** 当前激活的顶部一级菜单 code(高亮包含当前路由的一级菜单)。 */
const activeIndex = computed(() => {
const path = props.activePath
for (const m of props.menus) {
if ((m.path ?? m.code) === path) return m.code
if (m.children?.some((c) => (c.path ?? c.code) === path)) return m.code
}
return path
})
const emit = defineEmits<{
(e: 'toggle-collapse'): void
(e: 'toggle-dark'): void
(e: 'toggle-fullscreen'): void
(e: 'open-settings'): void
(e: 'navigate', path: string): void
(e: 'change-password'): void
(e: 'logout'): void
}>()
function onNavSelect(index: string): void {
emit('navigate', index)
}
function onUserCommand(command: string): void {
if (command === 'logout') emit('logout')
else if (command === 'change-password') emit('change-password')
}
</script>
<style scoped lang="scss">
.ty-header {
@apply flex items-center bg-bg-base pr-3;
height: var(--ty-header-height, 56px);
box-shadow: 0 1px 4px rgb(0 21 41 / 6%);
z-index: 100;
&.is-fixed {
@apply sticky top-0;
}
&__left {
@apply flex items-center gap-2;
}
&__logo {
@apply flex items-center justify-center gap-2 shrink-0 overflow-hidden bg-bg-base border-r border-solid px-2;
height: var(--ty-header-height, 56px);
border-color: var(--wm-border-color-light);
}
&__logo-img {
@apply w-[30px] h-[30px] shrink-0;
}
&__title {
@apply tracking-[0.5px] text-lg font-semibold text-text-primary whitespace-nowrap;
}
&__action {
@apply inline-flex items-center justify-center w-[34px] h-[34px] border-none rounded-base bg-transparent text-[18px] text-text-regular cursor-pointer transition-colors duration-200;
&:hover {
@apply bg-bg-page;
color: var(--el-color-primary);
}
}
&__nav {
@apply ml-1;
height: var(--ty-header-height, 56px);
// 覆盖 EP 横向菜单根元素默认的 1px 底边框(提高选择器优先级)
&.el-menu--horizontal {
@apply border-b-0;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
@apply text-text-regular;
height: var(--ty-header-height, 56px);
line-height: var(--ty-header-height, 56px);
}
:deep(.el-menu-item.is-active) {
@apply border-b-0;
color: var(--el-color-primary);
}
:deep(.menu-icon) {
@apply mr-1 text-base;
}
}
&__breadcrumb {
@apply ml-3;
}
&__right {
@apply ml-auto flex items-center gap-0.5;
}
&__user {
@apply inline-flex items-center gap-1.5 px-2 text-base text-text-regular cursor-pointer;
&:hover {
color: var(--el-color-primary);
}
}
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<aside class="ty-mixbar">
<div
v-for="item in menus"
:key="item.code"
class="ty-mixbar__item"
:class="{ 'is-active': item.code === activeCode }"
:title="item.name"
@click="emit('select', item.code)"
>
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" />
</div>
</aside>
</template>
<script setup lang="ts">
import type { ResourceNode } from '@/types/resource'
defineOptions({ name: 'TyMixbar' })
withDefaults(
defineProps<{
/** 一级菜单(用于展示图标)。 */
menus: ResourceNode[]
/** 当前激活的一级 code。 */
activeCode?: string
}>(),
{
activeCode: '',
},
)
const emit = defineEmits<{
(e: 'select', code: string): void
}>()
</script>
<style scoped lang="scss">
.ty-mixbar {
@apply sticky self-start flex flex-col items-center w-16 shrink-0 bg-bg-base border-r border-solid pt-2;
top: var(--ty-sidebar-top, 0);
height: var(--ty-sidebar-height, 100vh);
border-color: var(--wm-border-color-light);
&__item {
@apply flex items-center justify-center w-12 h-12 mb-1 rounded-base cursor-pointer text-[22px] text-text-regular;
&:hover {
@apply bg-bg-page;
}
&.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<ty-form :schema="visibleSchema" :model-value="modelValue" inline enter-to-submit @submit="emit('search')">
<el-form-item class="ty-search-form__actions">
<el-button v-if="collapsible" link type="primary" @click="expanded = !expanded">
{{ expanded ? '收起' : '展开' }}
<icon type="iconify" :icon="expanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
</el-button>
<el-button type="primary" @click="emit('search')">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</ty-form>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { JsonSchema, JsonSchemaProperty } from '@/types/crud'
defineOptions({ name: 'TySearchForm' })
const props = withDefaults(
defineProps<{
/** 搜索字段 JSON Schema。 */
schema: JsonSchema
/** 搜索条件(就地绑定)。 */
modelValue: Record<string, any>
/** 折叠时展示的字段数量。 */
collapseCount?: number
}>(),
{
collapseCount: 3,
},
)
const emit = defineEmits<{
(e: 'search'): void
(e: 'reset'): void
}>()
const expanded = ref(false)
const collapsible = computed(() => Object.keys(props.schema.properties).length > props.collapseCount)
const visibleSchema = computed<JsonSchema>(() => {
if (expanded.value || !collapsible.value) return props.schema
const keys = Object.keys(props.schema.properties).slice(0, props.collapseCount)
const properties: Record<string, JsonSchemaProperty> = {}
for (const k of keys) properties[k] = props.schema.properties[k]
return { type: 'object', properties, required: props.schema.required?.filter((r) => keys.includes(r)) }
})
function handleReset(): void {
for (const [key, prop] of Object.entries(props.schema.properties)) {
props.modelValue[key] = prop.default ?? ''
}
emit('reset')
}
</script>
<style scoped lang="scss">
.ty-search-form__actions {
@apply ml-auto mb-0;
}
</style>

View File

@ -0,0 +1,120 @@
<template>
<div class="ty-setting-panel">
<div class="ty-setting-panel__section">
<div class="ty-setting-panel__title">布局</div>
<el-radio-group v-model="local.layout" @change="emitUpdate">
<el-radio-button value="horizontal">上下</el-radio-button>
<el-radio-button value="top">纯上下</el-radio-button>
<el-radio-button value="sidebar">左右</el-radio-button>
</el-radio-group>
</div>
<div class="ty-setting-panel__section">
<div class="ty-setting-panel__row">
<span>MixBar</span>
<el-switch v-model="local.mixbar" :disabled="local.layout !== 'sidebar'" @change="emitUpdate" />
</div>
<div class="ty-setting-panel__row">
<span>暗黑模式</span>
<el-switch v-model="local.isDark" @change="emitUpdate" />
</div>
<div class="ty-setting-panel__row">
<span>固定顶栏</span>
<el-switch v-model="local.headerFixed" @change="emitUpdate" />
</div>
<div class="ty-setting-panel__row">
<span>固定底部</span>
<el-switch v-model="local.footerFixed" @change="emitUpdate" />
</div>
</div>
<div class="ty-setting-panel__section">
<div class="ty-setting-panel__row">
<span>主题色</span>
<el-color-picker v-model="local.themeColor" @change="emitUpdate" />
</div>
<div class="ty-setting-panel__row ty-setting-panel__row--block">
<span>侧边菜单宽度</span>
<el-slider v-model="local.sidebarWidth" :min="SIDEBAR_WIDTH_MIN" :max="SIDEBAR_WIDTH_MAX" @change="emitUpdate" />
</div>
</div>
<div class="ty-setting-panel__section">
<div class="ty-setting-panel__title">界面</div>
<div class="ty-setting-panel__row">
<span>面包屑</span>
<el-switch v-model="local.showBreadcrumb" @change="emitUpdate" />
</div>
<div class="ty-setting-panel__row">
<span>多页签</span>
<el-switch v-model="local.showTabs" @change="emitUpdate" />
</div>
<div class="ty-setting-panel__row">
<span>顶栏全屏</span>
<el-switch v-model="local.showFullscreen" @change="emitUpdate" />
</div>
<div class="ty-setting-panel__row">
<span>菜单手风琴</span>
<el-switch v-model="local.menuAccordion" @change="emitUpdate" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { LayoutSettings } from '@/config/layout'
import { SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX } from '@/config/layout'
defineOptions({ name: 'TySettingPanel' })
const props = defineProps<{
/** 当前布局/主题设置。 */
settings: LayoutSettings
}>()
const emit = defineEmits<{
(e: 'update', settings: LayoutSettings): void
}>()
const local = reactive<LayoutSettings>({ ...props.settings })
watch(
() => props.settings,
(v) => Object.assign(local, v),
{ deep: true },
)
function emitUpdate(): void {
emit('update', { ...local })
}
</script>
<style scoped lang="scss">
.ty-setting-panel {
&__section {
@apply border-b border-solid py-3;
border-color: var(--wm-border-color-light);
&:last-child {
@apply border-b-0;
}
}
&__title {
@apply mb-3 text-sm font-medium text-text-primary;
}
&__row {
@apply mb-3.5 flex items-center justify-between text-sm text-text-regular;
&:last-child {
@apply mb-0;
}
&--block {
@apply flex-col items-stretch;
}
}
}
</style>

View File

@ -0,0 +1,22 @@
<template>
<el-drawer v-model="open" title="布局设置" :size="320" append-to-body>
<ty-setting-panel :settings="settings" @update="emit('update', $event)" />
</el-drawer>
</template>
<script setup lang="ts">
import type { LayoutSettings } from '@/config/layout'
defineOptions({ name: 'TySetting' })
const open = defineModel<boolean>({ default: false })
defineProps<{
/** 当前布局/主题设置。 */
settings: LayoutSettings
}>()
const emit = defineEmits<{
(e: 'update', settings: LayoutSettings): void
}>()
</script>

View File

@ -0,0 +1,123 @@
<template>
<aside class="ty-sidebar" :style="{ width: `${collapsed ? COLLAPSED_WIDTH : width}px` }">
<el-scrollbar>
<el-menu
:default-active="activePath"
:collapse="collapsed"
:unique-opened="accordion"
:collapse-transition="false"
popper-class="ty-menu-popper"
@select="onSelect"
>
<template v-for="item in menus" :key="item.code">
<el-sub-menu v-if="item.children?.length" :index="item.code">
<template #title>
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" />
<span>{{ item.name }}</span>
</template>
<el-menu-item v-for="child in item.children" :key="child.code" :index="child.path ?? child.code">
<icon v-if="child.icon" type="iconify" :icon="child.icon" />
<span>{{ child.name }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="item.path ?? item.code">
<icon type="iconify" :icon="item.icon ?? 'mdi:menu'" />
<span>{{ item.name }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</aside>
</template>
<script setup lang="ts">
import type { ResourceNode } from '@/types/resource'
defineOptions({ name: 'TySidebar' })
const COLLAPSED_WIDTH = 64
withDefaults(
defineProps<{
/** 菜单树(字段对齐后端 resource)。 */
menus: ResourceNode[]
/** 是否折叠。 */
collapsed?: boolean
/** 展开时的宽度。 */
width?: number
/** 手风琴模式。 */
accordion?: boolean
/** 当前激活路径。 */
activePath?: string
}>(),
{
collapsed: false,
width: 210,
accordion: false,
activePath: '',
},
)
const emit = defineEmits<{
(e: 'select', path: string): void
}>()
function onSelect(index: string): void {
emit('select', index)
}
</script>
<style scoped lang="scss">
.ty-sidebar {
@apply sticky self-start shrink-0 overflow-hidden bg-bg-base border-r border-solid;
top: var(--ty-sidebar-top, 0);
height: var(--ty-sidebar-height, 100vh);
border-color: var(--wm-border-color-light);
@apply transition-[width] duration-200;
.el-menu {
@apply border-r-0;
--el-menu-bg-color: transparent;
--el-menu-text-color: var(--wm-color-text-regular);
--el-menu-hover-bg-color: var(--wm-bg-color-page);
--el-menu-active-color: var(--el-color-primary);
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
@apply my-0.5 mx-2 h-11 rounded-base;
line-height: 44px;
}
:deep(.el-menu-item.is-active) {
@apply font-medium;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
@apply bg-bg-page;
}
:deep(.icon) {
@apply mr-2;
}
}
.el-menu--collapse {
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
@apply my-0.5 mx-1;
}
:deep(.icon) {
@apply mr-0 text-[24px];
}
}
}
/* 折叠时弹出的子菜单(teleport 到 body,需全局样式)图标与文字间距 */
:global(.ty-menu-popper .el-menu-item .icon) {
@apply mr-2;
}
</style>

163
src/components/ty-table.vue Normal file
View File

@ -0,0 +1,163 @@
<template>
<div class="ty-table">
<div class="ty-table__body">
<el-table
v-loading="loading"
:data="data"
border
height="100%"
:row-key="rowKey"
:tree-props="treeProps"
:default-expand-all="defaultExpandAll"
@selection-change="onSelectionChange"
>
<el-table-column v-if="selection" type="selection" width="48" />
<el-table-column
v-for="col in columns"
:key="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align"
>
<template #default="{ row }">
<el-tag
v-if="col.type === 'tag'"
:type="tagInfo(col, row[col.prop]).type"
size="small"
>
{{ tagInfo(col, row[col.prop]).label }}
</el-tag>
<span v-else-if="col.type === 'datetime'">{{ formatDateTime(row[col.prop]) }}</span>
<span v-else-if="col.formatter">{{ col.formatter(row[col.prop], row) }}</span>
<span v-else>{{ row[col.prop] }}</span>
</template>
</el-table-column>
<el-table-column v-if="$slots.actions" label="操作" :width="actionsWidth" align="center" fixed="right">
<template #default="{ row }">
<slot name="actions" :row="row" />
</template>
</el-table-column>
</el-table>
</div>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
class="ty-table__pagination"
:total="total"
:page-sizes="pageSizes"
layout="total, sizes, prev, pager, next, jumper"
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TableColumn, TagType } from '@/types/crud'
import { useDictStore } from '@/stores/modules/dict'
defineOptions({ name: 'TyTable' })
const dictStore = useDictStore()
const props = withDefaults(
defineProps<{
/** 列配置。 */
columns: TableColumn[]
/** 表格数据。 */
data: Record<string, any>[]
loading?: boolean
/** 是否启用多选列。 */
selection?: boolean
/** 总条数(分页用)。 */
total?: number
page?: number
pageSize?: number
pageSizes?: number[]
/** 操作列宽度。 */
actionsWidth?: number | string
/** 树形数据的行唯一键。 */
rowKey?: string
/** 树形结构字段映射。 */
treeProps?: Record<string, string>
/** 是否默认展开所有树节点。 */
defaultExpandAll?: boolean
}>(),
{
loading: false,
selection: false,
total: 0,
page: 1,
pageSize: 10,
pageSizes: () => [10, 20, 50],
actionsWidth: 200,
rowKey: 'id',
treeProps: () => ({ children: 'children' }),
defaultExpandAll: false,
},
)
const emit = defineEmits<{
(e: 'update:page', page: number): void
(e: 'update:pageSize', pageSize: number): void
(e: 'selection-change', rows: Record<string, any>[]): void
}>()
const currentPage = computed({
get: () => props.page,
set: (v) => emit('update:page', v),
})
const currentPageSize = computed({
get: () => props.pageSize,
set: (v) => emit('update:pageSize', v),
})
function onPageChange(page: number): void {
emit('update:page', page)
}
function onSizeChange(size: number): void {
emit('update:pageSize', size)
}
function onSelectionChange(rows: Record<string, any>[]): void {
emit('selection-change', rows)
}
/** 标签渲染:dictCode 优先,其次 tagMap;无映射时回退原始值。 */
function tagInfo(col: TableColumn, value: unknown): { label: string; type: TagType } {
const key = String(value ?? '')
if (col.dictCode) {
const item = (dictStore.dicts[col.dictCode] ?? []).find((i) => i.value === key)
if (item) return { label: item.label, type: (item.colorType ?? 'info') as TagType }
return { label: key, type: 'info' }
}
return col.tagMap?.[key] ?? { label: key, type: 'info' }
}
function formatDateTime(value: unknown): string {
if (value == null || value === '') return '-'
const d = typeof value === 'number' ? new Date(value) : new Date(String(value))
if (Number.isNaN(d.getTime())) return '-'
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
</script>
<style scoped lang="scss">
.ty-table {
@apply flex h-full flex-col;
}
.ty-table__body {
@apply min-h-0 flex-1;
}
.ty-table__pagination {
@apply mt-md flex-shrink-0 justify-end;
}
</style>

129
src/components/ty-tabs.vue Normal file
View File

@ -0,0 +1,129 @@
<template>
<div v-if="tabs.length" class="ty-tabs">
<div
v-for="tab in tabs"
:key="tab.path"
class="ty-tabs__item"
:class="{ 'is-active': tab.path === activePath }"
@click="emit('click', tab.path)"
@contextmenu.prevent="openMenu($event, tab)"
>
<span class="ty-tabs__title">{{ tab.title }}</span>
<span v-if="tab.closable" class="ty-tabs__close" @click.stop="emit('remove', tab.path)">
<icon type="iconify" icon="mdi:close" />
</span>
</div>
<div
v-if="menu.visible"
class="ty-tabs__context-menu"
:style="{ left: `${menu.x}px`, top: `${menu.y}px` }"
@click.stop
>
<div class="ty-tabs__context-item" @click="onMenu('close-others')">关闭其他</div>
<div class="ty-tabs__context-item" @click="onMenu('close-left')">关闭左侧</div>
<div class="ty-tabs__context-item" @click="onMenu('close-right')">关闭右侧</div>
<div class="ty-tabs__context-item" @click="onMenu('close-all')">关闭全部</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive } from 'vue'
import type { TabItem } from '@/stores/modules/tabs'
defineOptions({ name: 'TyTabs' })
defineProps<{
/** 打开的页签。 */
tabs: TabItem[]
/** 当前激活的路径。 */
activePath: string
}>()
const emit = defineEmits<{
(e: 'click', path: string): void
(e: 'remove', path: string): void
(e: 'close-others', path: string): void
(e: 'close-left', path: string): void
(e: 'close-right', path: string): void
(e: 'close-all'): void
}>()
const menu = reactive({ visible: false, x: 0, y: 0, path: '' })
function openMenu(e: MouseEvent, tab: TabItem): void {
menu.x = e.clientX
menu.y = e.clientY
menu.path = tab.path
menu.visible = true
}
function onMenu(action: 'close-others' | 'close-left' | 'close-right' | 'close-all'): void {
menu.visible = false
switch (action) {
case 'close-all':
emit('close-all')
break
case 'close-others':
emit('close-others', menu.path)
break
case 'close-left':
emit('close-left', menu.path)
break
case 'close-right':
emit('close-right', menu.path)
break
}
}
function closeMenu(): void {
menu.visible = false
}
onMounted(() => document.addEventListener('click', closeMenu))
onBeforeUnmount(() => document.removeEventListener('click', closeMenu))
</script>
<style scoped lang="scss">
.ty-tabs {
@apply flex items-center gap-1 overflow-x-auto px-2 h-9 bg-bg-base border-b border-solid;
border-color: var(--wm-border-color-light);
&__item {
@apply flex flex-shrink-0 cursor-pointer items-center gap-1 h-7 px-2.5 rounded-base text-sm text-text-regular;
&:hover {
@apply bg-bg-page;
}
&.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
&__close {
@apply flex items-center justify-center w-4 h-4 rounded-full text-xs;
&:hover {
@apply text-white;
background: var(--wm-color-text-placeholder);
}
}
&__context-menu {
@apply fixed z-50 min-w-[110px] py-1 bg-bg-base rounded-base border border-solid;
border-color: var(--wm-border-color-light);
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
&__context-item {
@apply cursor-pointer py-1.5 px-3.5 text-sm text-text-regular;
&:hover {
@apply bg-bg-page;
}
}
}
</style>

View File

@ -0,0 +1,27 @@
import { computed } from 'vue'
import { useDictStore } from '@/stores/modules/dict'
import type { TagType } from '@/types/crud'
/**
* 读取某个字典类型的字典项,返回三种视图:
* - items: 响应式字典项数组(label/value/colorType/sort)
* - tagMap: value → { label, type },供表格标签列使用
* - options: { label, value }[],供下拉选择使用
*/
export function useDict(code: string) {
const store = useDictStore()
const items = computed(() => store.dicts[code] ?? [])
const tagMap = computed(() => {
const map: Record<string, { label: string; type: TagType }> = {}
for (const it of items.value) {
map[it.value] = { label: it.label, type: (it.colorType ?? 'info') as TagType }
}
return map
})
const options = computed(() => items.value.map((it) => ({ label: it.label, value: it.value })))
return { items, tagMap, options }
}

View File

@ -0,0 +1,114 @@
import { onMounted, onScopeDispose, ref, watch, type Ref } from 'vue'
import { isCancel } from 'axios'
/** fetcher:业务参数在前,AbortSignal 固定作为最后一个参数传入(用于取消请求)。 */
export type RequestFetcher<TData, TParams extends unknown[]> = (...args: [...TParams, AbortSignal]) => Promise<TData>
export interface UseRequestOptions<TData, TParams extends unknown[]> {
/** true 时挂载不自动请求,需手动 run。 */
manual?: boolean
/** 首次自动请求 / run() 无参时使用的参数。 */
defaultParams?: TParams
/** 响应式依赖,变化时自动用上次参数重新请求。 */
refreshDeps?: unknown[]
onBefore?: (params: TParams) => void
onSuccess?: (data: TData, params: TParams) => void
onError?: (error: unknown, params: TParams) => void
onFinally?: (params: TParams) => void
}
export interface UseRequestResult<TData, TParams extends unknown[]> {
data: Ref<TData | undefined>
loading: Ref<boolean>
error: Ref<unknown>
/** 手动触发(成功返回 data,失败/取消返回 undefined)。 */
run: (...args: TParams) => Promise<TData | undefined>
/** 用上一次的参数重新请求。 */
refresh: () => Promise<TData | undefined>
/** 取消当前请求。 */
cancel: () => void
/** 本地修改 data(不回发请求)。 */
mutate: (data: TData | ((prev: TData | undefined) => TData)) => void
}
/**
* 数据请求 Hook(类似 ahooks 的 useRequest):
* 管理 data/loading/error 状态,支持手动触发、依赖刷新、取消与本地修改。
*/
export function useRequest<TData, TParams extends unknown[]>(
fetcher: RequestFetcher<TData, TParams>,
options: UseRequestOptions<TData, TParams> = {},
): UseRequestResult<TData, TParams> {
const { manual = false, defaultParams = [] as unknown as TParams, refreshDeps } = options
const data = ref<TData>()
const loading = ref(false)
const error = ref<unknown>()
let lastParams: TParams = defaultParams
let controller: AbortController | null = null
let seq = 0
async function run(...args: TParams): Promise<TData | undefined> {
controller?.abort()
controller = new AbortController()
const mySeq = ++seq
lastParams = args.length > 0 ? args : lastParams
loading.value = true
error.value = undefined
options.onBefore?.(lastParams)
try {
const result = await fetcher(...lastParams, controller.signal)
if (mySeq !== seq) return undefined // 已被更新的请求取代
data.value = result
options.onSuccess?.(result, lastParams)
return result
} catch (e) {
if (isCanceled(e)) return undefined
if (mySeq !== seq) return undefined
error.value = e
options.onError?.(e, lastParams)
return undefined
} finally {
if (mySeq === seq) loading.value = false
options.onFinally?.(lastParams)
}
}
function refresh(): Promise<TData | undefined> {
return run(...lastParams)
}
function cancel(): void {
controller?.abort()
}
function mutate(next: TData | ((prev: TData | undefined) => TData)): void {
data.value = typeof next === 'function' ? (next as (prev: TData | undefined) => TData)(data.value) : next
}
if (refreshDeps) {
watch(refreshDeps, () => {
refresh()
})
}
if (!manual) {
onMounted(() => {
run(...defaultParams)
})
}
onScopeDispose(() => {
controller?.abort()
})
return { data, loading, error, run, refresh, cancel, mutate }
}
/** 判断是否 axios 主动取消(AbortController)。 */
function isCanceled(e: unknown): boolean {
return isCancel(e)
}

36
src/config/layout.ts Normal file
View File

@ -0,0 +1,36 @@
/** 布局类型:horizontal 上下(顶部一级导航+左侧菜单)/ top 纯上下(仅顶部)/ sidebar 左右(仅左侧菜单)。 */
export type LayoutType = 'horizontal' | 'top' | 'sidebar'
/** 布局/主题设置。默认值在此集中配置,改一处统一生效(用户页面修改覆盖并持久化)。 */
export interface LayoutSettings {
layout: LayoutType
mixbar: boolean
themeColor: string
isDark: boolean
headerFixed: boolean
sidebarWidth: number
footerFixed: boolean
sidebarCollapsed: boolean
showBreadcrumb: boolean
showTabs: boolean
showFullscreen: boolean
menuAccordion: boolean
}
export const SIDEBAR_WIDTH_MIN = 200
export const SIDEBAR_WIDTH_MAX = 340
export const defaultLayout: LayoutSettings = {
layout: 'horizontal',
mixbar: false,
themeColor: '#2E7FA3',
isDark: false,
headerFixed: true,
sidebarWidth: 210,
footerFixed: false,
sidebarCollapsed: false,
showBreadcrumb: true,
showTabs: true,
showFullscreen: true,
menuAccordion: false,
}

63
src/config/login.ts Normal file
View File

@ -0,0 +1,63 @@
import type { JsonSchema } from '@/types/crud'
import logo from '@/assets/images/logo.png'
/** 登录页布局:split=左右分栏(左品牌右表单)/ card=居中卡片。 */
export type LoginLayout = 'split' | 'card'
/** 登录页配置。默认值在此集中配置,fork 项目时改这里即可。 */
export interface LoginConfig {
/** 系统名称。 */
title: string
/** Logo 图片地址。 */
logo?: string
/** 副标题/标语。 */
subtitle?: string
/** 版权信息。 */
copyright: string
/** 布局形态。 */
layout: LoginLayout
/** 背景(图 URL 或 CSS 渐变/颜色)。 */
background?: string
/** 登录提交的租户编码(页面不展示租户输入框,统一用此默认租户)。 */
tenantCode: string
/** 登录字段(JSON Schema,字段名须与后端 DTO 一致)。 */
fields: JsonSchema
/** 是否显示「记住我」。 */
showRemember: boolean
/** 是否显示「忘记密码」。 */
showForgetPassword: boolean
/** 是否显示「注册」。 */
showRegister: boolean
/** 是否显示验证码(当前为 UI 占位,后端 DTO 暂无 captcha)。 */
showCaptcha: boolean
/** 登录成功跳转路径。 */
homePath: string
}
export const defaultLoginConfig: LoginConfig = {
title: '投云系统管理平台',
logo,
subtitle: '通用系统管理脚手架',
copyright: '云南投云科技有限公司',
layout: 'split',
tenantCode: 'touyun',
fields: {
type: 'object',
properties: {
username: { type: 'string', title: '用户名', placeholder: '请输入用户名', prefixIcon: 'mdi:account' },
password: {
type: 'string',
title: '密码',
format: 'password',
placeholder: '请输入密码',
prefixIcon: 'mdi:lock',
},
},
required: ['username', 'password'],
},
showRemember: true,
showForgetPassword: false,
showRegister: false,
showCaptcha: true,
homePath: '/',
}

View File

@ -0,0 +1,25 @@
import { watchEffect, type Directive } from 'vue'
import { usePermissionStore } from '@/stores/modules/permission'
const stops = new WeakMap<HTMLElement, () => void>()
/**
* v-permission 指令:当前用户不具备指定权限码(且非超管)时隐藏元素。
* 用法:v-permission="'user:create'" 或 v-permission="['user:create', 'user:update']"(任一满足即可)。
* 权限异步加载:用 watchEffect 响应式隐藏/恢复,权限加载完成后自动显示。
*/
export const vPermission: Directive<HTMLElement, string | string[]> = {
mounted(el, binding) {
const store = usePermissionStore()
const required = (Array.isArray(binding.value) ? binding.value : [binding.value]).filter(Boolean) as string[]
if (required.length === 0) return
const stop = watchEffect(() => {
el.style.display = store.hasAny(required) ? '' : 'none'
})
stops.set(el, stop)
},
unmounted(el) {
stops.get(el)?.()
stops.delete(el)
},
}

12
src/layouts/blank.vue Normal file
View File

@ -0,0 +1,12 @@
<!--
@name: blank.vue
@description: 空布局
@author: 程序员优雅哥 youyacoder
@time: 2026/10/11 11:27
-->
<template>
<router-view />
</template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@ -1,23 +1,330 @@
<!--
@name: default.vue.vue
@description:
@author: 程序员优雅哥 youyacoder
@time: 2026/2/11 16:04
-->
<template>
<div>
<header>
<router-link to="/" class="item">home</router-link>
<router-link to="/demo" class="item">demo</router-link>
</header>
<router-view></router-view>
<div class="app-shell" :class="{ 'is-not-fixed': !app.headerFixed }" :style="cssVars">
<ty-header
:title="title"
:logo="logo"
:show-title="showTitle"
:logo-width="logoWidth"
:has-sidebar="showSidebar"
:collapsed="app.sidebarCollapsed"
:fixed="app.headerFixed"
:show-breadcrumb="app.showBreadcrumb"
:breadcrumbs="breadcrumbs"
:show-fullscreen="app.showFullscreen"
:is-fullscreen="isFullscreen"
:is-dark="app.isDark"
:layout="app.layout"
:menus="menus"
:active-path="activePath"
:username="username"
@toggle-collapse="app.sidebarCollapsed = !app.sidebarCollapsed"
@toggle-dark="app.isDark = !app.isDark"
@toggle-fullscreen="toggleFullscreen"
@open-settings="settingsOpen = true"
@navigate="handleNavigate"
@change-password="handleChangePassword"
@logout="handleLogout"
/>
<div class="app-body">
<ty-mixbar
v-if="app.hasMixbar"
:menus="menus"
:active-code="activeTop?.code ?? ''"
@select="handleTopSelect"
/>
<ty-sidebar
v-if="showSidebar"
:menus="sidebarMenus"
:collapsed="app.sidebarCollapsed"
:width="app.sidebarWidth"
:accordion="app.menuAccordion"
:active-path="activePath"
@select="handleNavigate"
/>
<main class="app-content">
<ty-tabs
v-if="app.showTabs"
:tabs="tabsStore.tabs"
:active-path="activePath"
@click="handleTabClick"
@remove="handleTabRemove"
@close-others="handleCloseOthers"
@close-left="handleCloseLeft"
@close-right="handleCloseRight"
@close-all="handleCloseAll"
/>
<div class="app-main">
<router-view />
</div>
<ty-footer :fixed="app.footerFixed" />
</main>
</div>
<ty-setting v-model="settingsOpen" :settings="settings" @update="applySettings" />
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAppStore } from '@/stores/modules/app'
import { HOME_PATH, useTabsStore } from '@/stores/modules/tabs'
import { useAuthStore } from '@/stores/modules/auth'
import { useMenuStore } from '@/stores/modules/menu'
import { useDictStore } from '@/stores/modules/dict'
import { usePermissionStore } from '@/stores/modules/permission'
import logo from '@/assets/images/logo.png'
import type { LayoutSettings } from '@/config/layout'
import type { ResourceNode } from '@/types/resource'
import type { BreadcrumbItem } from '@/components/ty-breadcrumb.vue'
import { applyTheme } from '@/utils/theme'
import { showDialog } from '@/utils/dialog'
import { changePassword } from '@/api/auth'
import ChangePasswordForm from '@/components/change-password-form.vue'
const app = useAppStore()
const tabsStore = useTabsStore()
const auth = useAuthStore()
const menuStore = useMenuStore()
const dictStore = useDictStore()
const permissionStore = usePermissionStore()
const route = useRoute()
const router = useRouter()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const title = '投云科技'
const HEADER_HEIGHT = 56
const menus = computed(() => menuStore.menus)
const settingsOpen = ref(false)
// 登录后拉取菜单、字典与权限码
onMounted(() => {
menuStore.fetch()
dictStore.fetch()
permissionStore.fetch()
})
const activePath = computed(() => route.path)
/** 当前路由对应的顶级菜单(及其叶子)。 */
const activeTop = computed<ResourceNode | null>(() => {
for (const m of menus.value) {
if ((m.path ?? m.code) === activePath.value) return m
if (m.children?.some((c) => (c.path ?? c.code) === activePath.value)) return m
}
return null
})
/** 侧边栏菜单:horizontal/mixbar 显示当前顶级下的子菜单;sidebar 显示全树。 */
const sidebarMenus = computed<ResourceNode[]>(() => {
if (app.layout === 'top') return []
if (app.layout === 'horizontal') return activeTop.value?.children ?? []
return app.mixbar ? (activeTop.value?.children ?? []) : menus.value
})
/** 是否显示左侧菜单:有侧栏且当前顶级下存在子菜单。 */
const showSidebar = computed(() => app.hasSidebar && sidebarMenus.value.length > 0)
/** 面包屑:顶级 + 当前叶子。 */
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
const top = activeTop.value
if (!top) return []
if ((top.path ?? top.code) === activePath.value) return [{ title: top.name }]
const leaf = top.children?.find((c) => (c.path ?? c.code) === activePath.value)
if (leaf) return [{ title: top.name }, { title: leaf.name }]
return [{ title: top.name }]
})
/** Logo 区宽度:与左侧栏(MixBar+菜单)对齐;无左侧栏时自适应。 */
const logoWidth = computed<number | null>(() => {
const mix = app.hasMixbar ? 64 : 0
const side = showSidebar.value ? (app.sidebarCollapsed ? 64 : app.sidebarWidth) : 0
const total = mix + side
return total > 0 ? total : null
})
/** 侧栏折叠/隐藏时仅显 Logo,展开时显 Logo + 标题。 */
const showTitle = computed(() => {
if (logoWidth.value == null) return true
return showSidebar.value && !app.sidebarCollapsed
})
/** 侧边栏 sticky 定位(顶栏固定时往下让出顶栏高度)。 */
const cssVars = computed(() => ({
'--ty-header-height': `${HEADER_HEIGHT}px`,
'--ty-sidebar-top': app.headerFixed ? `${HEADER_HEIGHT}px` : '0px',
'--ty-sidebar-height': app.headerFixed ? `calc(100vh - ${HEADER_HEIGHT}px)` : '100vh',
}))
/** 布局设置快照(传给设置面板)。 */
const settings = computed<LayoutSettings>(() => ({
layout: app.layout,
mixbar: app.mixbar,
themeColor: app.themeColor,
isDark: app.isDark,
headerFixed: app.headerFixed,
sidebarWidth: app.sidebarWidth,
footerFixed: app.footerFixed,
sidebarCollapsed: app.sidebarCollapsed,
showBreadcrumb: app.showBreadcrumb,
showTabs: app.showTabs,
showFullscreen: app.showFullscreen,
menuAccordion: app.menuAccordion,
}))
/** 用户名(从 access token 解出)。 */
const username = computed(() => decodeUsername(auth.accessToken))
function applySettings(s: LayoutSettings): void {
app.$patch(s)
}
function handleNavigate(index: string): void {
const target = resolveTarget(index)
if (target) router.push(target)
}
function handleTopSelect(code: string): void {
const top = menus.value.find((m) => m.code === code)
const target = top ? firstLeafPath(top) : null
if (target) router.push(target)
}
function handleTabClick(path: string): void {
router.push(path)
}
function handleTabRemove(path: string): void {
const index = tabsStore.tabs.findIndex((t) => t.path === path)
const neighbor = tabsStore.tabs[index - 1] ?? tabsStore.tabs[index + 1] ?? tabsStore.tabs[0]
tabsStore.removeTab(path)
if (activePath.value === path) {
router.push(neighbor?.path ?? HOME_PATH)
}
}
function handleCloseOthers(path: string): void {
tabsStore.removeOthers(path)
router.push(path)
}
function handleCloseLeft(path: string): void {
tabsStore.removeLeft(path)
if (!tabsStore.tabs.some((t) => t.path === activePath.value)) router.push(path)
}
function handleCloseRight(path: string): void {
tabsStore.removeRight(path)
if (!tabsStore.tabs.some((t) => t.path === activePath.value)) router.push(path)
}
function handleCloseAll(): void {
tabsStore.removeAll()
router.push(HOME_PATH)
}
async function handleLogout(): Promise<void> {
await auth.logout()
menuStore.reset()
dictStore.reset()
permissionStore.reset()
tabsStore.removeAll()
router.push('/login')
}
async function handleChangePassword(): Promise<void> {
const result = await showDialog({
component: ChangePasswordForm,
title: '修改密码',
width: '420px',
})
if (result == null) return
await changePassword(result.oldPassword, result.newPassword)
ElMessage.success('密码已修改,请重新登录')
await handleLogout()
}
/** 把菜单索引(path 或 code)解析为可跳转的叶子路径。 */
function resolveTarget(index: string): string | null {
const node = findMenu(index)
if (!node) return index
if (node.children?.length) return firstLeafPath(node)
return node.path ?? node.code
}
function findMenu(index: string): ResourceNode | null {
for (const m of menus.value) {
if ((m.path ?? m.code) === index) return m
const child = m.children?.find((c) => (c.path ?? c.code) === index)
if (child) return child
}
return null
}
function firstLeafPath(node: ResourceNode): string | null {
if (!node.children?.length) return node.path ?? node.code
return firstLeafPath(node.children[0])
}
function decodeUsername(token: string | null): string {
if (!token) return ''
try {
const payload = token.split('.')[1]
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=')
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
const json = JSON.parse(new TextDecoder().decode(bytes))
return (json.username as string) ?? ''
} catch {
return ''
}
}
// 主题应用
watch(
[() => app.themeColor, () => app.isDark],
([color, dark]) => applyTheme(color, dark),
{ immediate: true },
)
// 页签同步
watch(
activePath,
(p) => {
const top = activeTop.value
const leaf = top?.children?.find((c) => (c.path ?? c.code) === p)
const title = leaf?.name ?? top?.name ?? p
tabsStore.addTab({ path: p, title, closable: p !== '/' })
},
{ immediate: true },
)
</script>
<style scoped lang="scss">
.item {
@apply line-height-loose mr-3 text-gray-400 mb-5;
.app-shell {
@apply flex flex-col h-screen overflow-hidden;
&.is-not-fixed {
@apply h-auto min-h-screen overflow-visible;
}
}
.app-body {
@apply flex flex-1 items-stretch min-h-0;
}
.app-content {
@apply flex flex-1 flex-col min-w-0 min-h-0;
}
.app-main {
@apply flex-1 min-h-0 p-4 overflow-y-auto bg-bg-page;
.is-not-fixed & {
@apply overflow-y-visible;
}
}
</style>

View File

@ -1,27 +1,39 @@
import { createApp } from 'vue'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import { installRouter } from './router'
import { installStore } from '@/stores'
import router, { installRouter } from './router'
import pinia, { installStore } from '@/stores'
import { installAssets } from '@/plugins/assets'
import { Env } from '@/utils/env.ts'
import { installI18n } from '@/i18n'
import { setAuthExpiredHandler } from '@/api/http'
import { useAuthStore } from '@/stores/modules/auth'
import { useMenuStore } from '@/stores/modules/menu'
import { useDictStore } from '@/stores/modules/dict'
import { usePermissionStore } from '@/stores/modules/permission'
import { useTabsStore } from '@/stores/modules/tabs'
import { vPermission } from '@/directives/permission'
const app = createApp(App)
app.directive('permission', vPermission)
installRouter(app)
installStore(app)
// 会话过期(401 刷新失败)时:清状态 + 跳登录
setAuthExpiredHandler(() => {
useAuthStore(pinia).reset()
useMenuStore(pinia).reset()
useDictStore(pinia).reset()
usePermissionStore(pinia).reset()
useTabsStore(pinia).removeAll()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
})
installI18n(app)
installAssets()
// 获取当前模式
const currentMode = import.meta.env.MODE
console.log('当前模式:', currentMode) // 输出: development, uat 或 production
console.log('VITE_APP_NAME: ', Env.get('VITE_APP_NAME'))
console.log('VITE_NUMBER_DEMO: ', Env.getNumber('VITE_NUMBER_DEMO'))
console.log('VITE_BOOLEAN_DEMO: ', Env.getBoolean('VITE_BOOLEAN_DEMO'))
console.log('isDev', Env.isDev)
console.log('isUat', Env.isUat)
console.log('isProd', Env.isProd)
app.mount('#app')

25
src/pages/403.vue Normal file
View File

@ -0,0 +1,25 @@
<template>
<div class="error-page">
<el-result icon="warning" title="403" sub-title="抱歉,你没有权限访问该页面">
<template #extra>
<el-button type="primary" @click="goHome">回到首页</el-button>
</template>
</el-result>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome(): void {
router.push('/')
}
</script>
<style scoped lang="scss">
.error-page {
@apply flex h-full items-center justify-center;
}
</style>

40
src/pages/[...all].vue Normal file
View File

@ -0,0 +1,40 @@
<template>
<div class="not-found">
<div class="not-found__code">404</div>
<div class="not-found__title">页面不存在</div>
<div class="not-found__desc">抱歉,你访问的路径「{{ fullPath }}」不存在或已被移除</div>
<el-button type="primary" @click="goHome">回到首页</el-button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const fullPath = computed(() => route.fullPath)
function goHome(): void {
router.push('/')
}
</script>
<style scoped lang="scss">
.not-found {
@apply flex h-full flex-col items-center justify-center gap-md;
}
.not-found__code {
@apply text-[96px] font-bold leading-none text-text-secondary;
}
.not-found__title {
@apply text-xl font-medium text-text-primary;
}
.not-found__desc {
@apply text-base text-text-regular;
}
</style>

View File

@ -0,0 +1,161 @@
<route lang="yaml">
meta:
layout: 'blank'
requiresAuth: false
</route>
<template>
<div class="auth">
<div class="auth__card">
<div class="auth__head">
<img v-if="config.logo" :src="config.logo" class="auth__logo" alt="logo" />
<h1 class="auth__title">忘记密码</h1>
<p class="auth__subtitle">输入账号以重置密码</p>
</div>
<el-form ref="formRef" class="auth__form" :model="model" :rules="rules" size="large" @keyup.enter="onSubmit">
<el-form-item prop="username">
<el-input v-model="model.username" placeholder="用户名">
<template #prefix><icon type="iconify" icon="mdi:account" /></template>
</el-input>
</el-form-item>
<el-form-item prop="newPassword">
<el-input v-model="model.newPassword" type="password" show-password placeholder="新密码">
<template #prefix><icon type="iconify" icon="mdi:lock" /></template>
</el-input>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="model.confirmPassword" type="password" show-password placeholder="确认新密码">
<template #prefix><icon type="iconify" icon="mdi:lock-check" /></template>
</el-input>
</el-form-item>
<el-button type="primary" size="large" class="auth__submit" :loading="loading" @click="onSubmit">
重 置 密 码
</el-button>
</el-form>
<div class="auth__footer">
想起来了?
<el-link type="primary" :underline="false" @click="router.push('/login')">返回登录</el-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAppStore } from '@/stores/modules/app'
import { defaultLoginConfig } from '@/config/login'
import { applyTheme } from '@/utils/theme'
const router = useRouter()
const app = useAppStore()
const config = defaultLoginConfig
// 忘记密码页不在布局组件内,手动应用主题
applyTheme(app.themeColor, app.isDark)
const formRef = ref<FormInstance>()
const loading = ref(false)
const model = reactive({ username: '', newPassword: '', confirmPassword: '' })
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value !== model.newPassword) callback(new Error('两次输入的密码不一致'))
else callback()
},
trigger: 'blur',
},
],
}
async function onSubmit(): Promise<void> {
if (loading.value) return
try {
await formRef.value?.validate()
} catch {
return
}
loading.value = true
// mock:后端暂无重置密码接口,模拟耗时
await new Promise((resolve) => setTimeout(resolve, 300))
loading.value = false
ElMessage.success('密码重置成功,请使用新密码登录')
router.push({ path: '/login', query: { username: model.username } })
}
</script>
<style scoped lang="scss">
.auth {
@apply flex min-h-screen items-center justify-center;
background: var(--wm-bg-color-page);
}
.auth__card {
@apply rounded-lg border border-solid p-2xl shadow-md w-[440px] max-w-[92vw];
background: var(--wm-bg-color-base);
border-color: var(--wm-border-color-light);
}
.auth__head {
@apply mb-lg text-center;
}
.auth__logo {
@apply mx-auto h-16 w-16 object-contain;
}
.auth__title {
@apply mt-md text-2xl font-semibold text-text-primary;
}
.auth__subtitle {
@apply mt-xs text-sm text-text-secondary;
}
.auth__form {
@apply w-full;
}
.auth__form :deep(.el-form-item) {
margin-bottom: 22px;
}
.auth__form :deep(.el-input__wrapper) {
@apply rounded-base transition-shadow duration-200;
background-color: var(--wm-bg-color-page);
box-shadow: none;
}
.auth__form :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px var(--wm-border-color-light) inset;
}
.auth__form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--wm-color-primary) inset;
}
.auth__submit {
@apply mt-lg h-11 w-full border-none text-base rounded-full tracking-[2px];
background: linear-gradient(90deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
&:hover {
@apply opacity-90;
}
}
.auth__footer {
@apply mt-lg text-center text-sm text-text-secondary;
}
</style>

View File

@ -1,61 +1,171 @@
<!--
@name: index.vue
@description: 首页
@author: 程序员优雅哥 youyacoder
@time: 2026/2/11 15:58
-->
<template>
<div class="flex gap-2">
<button
v-for="lang in languages"
:key="lang.value"
:class="{ active: currentLocale === lang.value }"
@click="onChangeLanguage(lang.value)"
>
{{ lang.label }}
</button>
</div>
<div>{{ $t('common.welcome', { name: '优雅哥' }) }}</div>
<div>{{ $t('home.title') }}</div>
<hr />
<div class="home">
<div class="home__banner">
<div class="home__banner-decor" aria-hidden="true">
<span class="home__banner-decor-1"></span>
<span class="home__banner-decor-2"></span>
</div>
<div class="home__banner-inner">
<h1 class="home__greeting">{{ greeting }},{{ username }}</h1>
<p class="home__datetime">{{ datetime }}</p>
<p class="home__subtitle">欢迎使用 {{ title }}</p>
</div>
</div>
<h1 class="index">index page</h1>
<div>{{ appName }}</div>
<div>{{ msg }}</div>
<div>isDark: {{ isDark }}</div>
<button @click="toggleDark()">Toggle</button>
<div class="home__modules">
<el-card v-for="m in topMenus" :key="m.code" class="home__module" shadow="never">
<div class="home__module-head">
<icon type="iconify" :icon="m.icon ?? 'mdi:folder'" class="home__module-icon" />
<span class="home__module-title">{{ m.name }}</span>
</div>
<div class="home__module-links">
<el-link
v-for="c in m.children"
:key="c.code"
class="home__module-link"
:underline="false"
@click="router.push(c.path ?? c.code)"
>
{{ c.name }}
</el-link>
</div>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { loadLanguage } from '@/i18n'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/modules/auth'
import { useMenuStore } from '@/stores/modules/menu'
import { defaultLoginConfig } from '@/config/login'
const { t, locale } = useI18n()
console.log(t('common.welcome', { name: '优雅哥' }))
const currentLocale = locale
// 支持的语言
const languages = [
{ label: '中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' },
]
const onChangeLanguage = async (lang: string) => {
// 加载语言包
await loadLanguage(lang)
// 设置当前语言
locale.value = lang
}
const router = useRouter()
const auth = useAuthStore()
const menuStore = useMenuStore()
const msg = ref('Hello world')
const title = defaultLoginConfig.title
const isDark = useDark({
selector: 'html',
attribute: 'data-theme',
valueDark: 'dark',
valueLight: 'light',
const username = computed(() => decodeUsername(auth.accessToken))
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '凌晨好'
if (h < 12) return '早上好'
if (h < 18) return '下午好'
return '晚上好'
})
const toggleDark = useToggle(isDark)
// 实时时间
const now = ref(new Date())
const timer = setInterval(() => {
now.value = new Date()
}, 1000)
onBeforeUnmount(() => clearInterval(timer))
const appName = import.meta.env?.VITE_APP_NAME ?? ''
const datetime = computed(() => {
const d = now.value
const pad = (n: number) => String(n).padStart(2, '0')
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 星期${week} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
})
/** 顶级菜单(有子菜单的,作为快捷入口)。 */
const topMenus = computed(() => menuStore.menus.filter((m) => m.children?.length))
function decodeUsername(token: string | null): string {
if (!token) return ''
try {
const payload = token.split('.')[1]
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=')
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
const json = JSON.parse(new TextDecoder().decode(bytes))
return (json.username as string) ?? ''
} catch {
return ''
}
}
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.home {
@apply flex flex-col gap-md;
}
.home__banner {
@apply relative overflow-hidden rounded-lg text-white;
background: linear-gradient(135deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
}
.home__banner-decor {
@apply absolute inset-0;
}
.home__banner-decor-1 {
@apply absolute rounded-full;
width: 260px;
height: 260px;
top: -100px;
right: -60px;
background: rgba(255, 255, 255, 0.08);
}
.home__banner-decor-2 {
@apply absolute rounded-full;
width: 160px;
height: 160px;
bottom: -60px;
right: 120px;
background: rgba(255, 255, 255, 0.05);
}
.home__banner-inner {
@apply relative px-xl py-2xl;
}
.home__greeting {
@apply text-2xl font-semibold;
}
.home__datetime {
@apply mt-sm text-base opacity-90;
}
.home__subtitle {
@apply mt-md text-sm opacity-75;
}
.home__modules {
@apply grid grid-cols-1 gap-md md:grid-cols-2 xl:grid-cols-3;
}
.home__module {
@apply transition-shadow hover:shadow-md;
:deep(.el-card__body) {
@apply p-lg;
}
}
.home__module-head {
@apply flex items-center gap-sm;
}
.home__module-icon {
@apply text-2xl;
color: var(--wm-color-primary);
}
.home__module-title {
@apply text-base font-medium text-text-primary;
}
.home__module-links {
@apply mt-md flex flex-wrap gap-x-lg gap-y-sm;
}
.home__module-link {
@apply text-sm text-text-regular;
}
</style>

View File

@ -1,61 +0,0 @@
<route lang="yaml">
meta:
layout: false
requiresAuth: false
</route>
<template>
<div class="login">
<form @submit.prevent="onSubmit">
<h1>登录</h1>
<input v-model="tenantCode" placeholder="租户编码" />
<input v-model="username" placeholder="用户名" />
<input v-model="password" type="password" placeholder="密码" />
<button type="submit">登录</button>
<p v-if="error">{{ error }}</p>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/modules/auth';
const router = useRouter();
const auth = useAuthStore();
const tenantCode = ref('');
const username = ref('');
const password = ref('');
const error = ref('');
async function onSubmit(): Promise<void> {
error.value = '';
try {
await auth.login(tenantCode.value, username.value, password.value);
router.push('/');
} catch (e) {
error.value = (e as Error).message;
}
}
</script>
<style scoped lang="scss">
.login {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 280px;
}
input,
button {
padding: 0.5rem;
}
</style>

428
src/pages/login/index.vue Normal file
View File

@ -0,0 +1,428 @@
<route lang="yaml">
meta:
layout: 'blank'
requiresAuth: false
</route>
<template>
<div class="login" :class="`login--${config.layout}`" :style="rootStyle">
<div v-if="config.layout === 'split'" class="login__brand" :style="brandStyle">
<div class="login__brand-decor" aria-hidden="true">
<span class="login__brand-decor-1"></span>
<span class="login__brand-decor-2"></span>
</div>
<div class="login__brand-inner">
<div v-if="config.logo" class="login__brand-logo-box">
<img :src="config.logo" class="login__brand-logo" alt="logo" />
</div>
<h1 class="login__brand-title">{{ config.title }}</h1>
<p v-if="config.subtitle" class="login__brand-subtitle">{{ config.subtitle }}</p>
</div>
<p class="login__brand-copyright">{{ config.copyright }}</p>
</div>
<div class="login__card">
<div class="login__panel">
<div v-if="config.layout === 'card'" class="login__panel-head">
<img v-if="config.logo" :src="config.logo" class="login__panel-logo" alt="logo" />
<div class="login__panel-title">{{ config.title }}</div>
<div v-if="config.subtitle" class="login__panel-subtitle">{{ config.subtitle }}</div>
</div>
<el-form
ref="formRef"
class="login__form"
:model="model"
:rules="rules"
size="large"
@keyup.enter="onSubmit"
>
<div v-if="config.layout === 'split'" class="login__form-head">
<h2 class="login__form-title">欢迎登录</h2>
<p class="login__form-subtitle">请输入账号信息以继续</p>
</div>
<el-form-item v-for="(prop, key) in config.fields.properties" :key="key" :prop="key">
<el-input
v-model="model[key]"
:type="prop.format === 'password' ? 'password' : 'text'"
:show-password="prop.format === 'password'"
:placeholder="prop.placeholder ?? String(prop.title ?? key)"
>
<template v-if="prop.prefixIcon" #prefix>
<icon type="iconify" :icon="prop.prefixIcon" />
</template>
</el-input>
</el-form-item>
<el-form-item v-if="config.showCaptcha">
<div class="login__captcha">
<el-input v-model="captcha" placeholder="请输入验证码">
<template #prefix>
<icon type="iconify" icon="mdi:shield-check-outline" />
</template>
</el-input>
<div class="login__captcha-code" @click="refreshCaptcha">{{ captchaCode }}</div>
</div>
</el-form-item>
<div class="login__row">
<el-checkbox v-if="config.showRemember" v-model="remember">记住我</el-checkbox>
<el-link
v-if="config.showForgetPassword"
type="primary"
:underline="false"
@click="onForget"
>
忘记密码?
</el-link>
</div>
<el-button
type="primary"
size="large"
class="login__submit"
:loading="loading"
@click="onSubmit"
>
登 录
</el-button>
<div v-if="config.showRegister" class="login__register">
还没有账号?
<el-link type="primary" :underline="false" @click="onRegister">立即注册</el-link>
</div>
<p v-if="error" class="login__error">{{ error }}</p>
</el-form>
<div v-if="config.layout === 'card'" class="login__panel-copyright">
{{ config.copyright }}
</div>
</div>
</div>
<el-dialog v-model="tenantDialogVisible" title="选择登录租户" width="360px" :close-on-click-modal="false">
<div class="login__tenants">
<div
v-for="t in tenantOptions"
:key="t.tenantCode"
class="login__tenant-item"
@click="selectTenant(t.tenantCode)"
>
<span>{{ t.tenantName }}</span>
<span class="login__tenant-code">{{ t.tenantCode }}</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/modules/auth'
import { useAppStore } from '@/stores/modules/app'
import { defaultLoginConfig } from '@/config/login'
import { applyTheme } from '@/utils/theme'
import { useRequest } from '@/composables/useRequest'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const app = useAppStore()
const config = defaultLoginConfig
// 登录页不在布局组件内,手动应用主题(颜色 + 暗黑)
applyTheme(app.themeColor, app.isDark)
const formRef = ref<FormInstance>()
const model = reactive<Record<string, any>>({})
for (const key of Object.keys(config.fields.properties)) model[key] = ''
// 注册/忘记密码成功后带 username 回填
const qUsername = route.query.username
if (qUsername) model.username = String(qUsername)
const captcha = ref('')
const captchaCode = ref('')
const remember = ref(true)
const error = ref('')
const tenantOptions = ref<{ tenantCode: string; tenantName: string }[]>([])
const tenantDialogVisible = ref(false)
const rootStyle = computed(() => (config.background ? { background: config.background } : {}))
/** 品牌区背景:暗黑模式用深色底 + 主色淡染,避免 --wm-color-primary-dark-2 在暗黑下偏亮。 */
const brandStyle = computed(() => {
const primary = app.themeColor
if (app.isDark) {
return { background: `linear-gradient(160deg, color-mix(in srgb, ${primary} 24%, #101820) 0%, #0c131a 100%)` }
}
return { background: `linear-gradient(160deg, ${primary} 0%, color-mix(in srgb, ${primary} 80%, #000) 100%)` }
})
const rules = computed<FormRules>(() => {
const result: FormRules = {}
for (const [key, prop] of Object.entries(config.fields.properties)) {
const itemRules: Record<string, unknown>[] = []
if (config.fields.required?.includes(key)) {
itemRules.push({ required: true, message: `请输入${prop.title ?? key}`, trigger: 'blur' })
}
if (prop.minLength) {
itemRules.push({
min: prop.minLength,
message: `长度不能少于 ${prop.minLength} 位`,
trigger: 'blur',
})
}
if (itemRules.length) result[key] = itemRules
}
return result
})
function refreshCaptcha(): void {
captchaCode.value = String(Math.floor(1000 + Math.random() * 9000))
}
refreshCaptcha()
const { loading, run: doLogin } = useRequest(
() => auth.login(model.username, model.password),
{
manual: true,
onSuccess: (data) => {
if (data && 'needSelectTenant' in data && data.needSelectTenant) {
tenantOptions.value = data.tenants
tenantDialogVisible.value = true
} else {
router.push(config.homePath)
}
},
onError: (e) => {
error.value = (e as Error).message
},
},
)
async function selectTenant(tenantCode: string): Promise<void> {
tenantDialogVisible.value = false
try {
await auth.login(model.username, model.password, tenantCode)
router.push(config.homePath)
} catch (e) {
error.value = (e as Error).message
}
}
async function onSubmit(): Promise<void> {
if (loading.value) return
error.value = ''
try {
await formRef.value?.validate()
} catch {
return
}
if (config.showCaptcha && captcha.value !== captchaCode.value) {
error.value = '验证码错误'
refreshCaptcha()
captcha.value = ''
return
}
await doLogin()
}
function onForget(): void {
router.push('/forgot-password')
}
function onRegister(): void {
router.push('/register')
}
</script>
<style scoped lang="scss">
.login {
@apply flex min-h-screen;
background: var(--wm-bg-color-page);
}
.login--card {
@apply items-center justify-center;
}
/* ---------- 品牌区(split) ---------- */
.login__brand {
@apply relative hidden md:flex flex-col overflow-hidden w-[44%] text-white;
}
.login__brand-decor {
@apply absolute inset-0;
}
.login__brand-decor-1 {
@apply absolute rounded-full;
width: 420px;
height: 420px;
top: -140px;
right: -140px;
background: rgba(255, 255, 255, 0.08);
}
.login__brand-decor-2 {
@apply absolute rounded-full;
width: 300px;
height: 300px;
bottom: -120px;
left: -100px;
background: rgba(255, 255, 255, 0.05);
}
.login__brand-inner {
@apply relative flex flex-1 flex-col items-center justify-center px-xl text-center;
}
.login__brand-logo-box {
@apply flex h-20 w-20 items-center justify-center rounded-lg p-sm backdrop-blur;
background: rgba(255, 255, 255, 0.16);
}
.login__brand-logo {
@apply h-full w-full object-contain;
}
.login__brand-title {
@apply mt-xl text-3xl font-semibold tracking-wide;
}
.login__brand-subtitle {
@apply mt-md text-base opacity-85;
}
.login__brand-copyright {
@apply relative mx-xl mb-lg border-t border-white/20 pt-lg text-center text-sm opacity-70;
}
/* ---------- 表单区 ---------- */
.login__card {
@apply flex flex-1 items-center justify-center px-xl;
}
.login__panel {
@apply rounded-lg border border-solid p-2xl shadow-md w-[440px] max-w-[92vw];
background: var(--wm-bg-color-base);
border-color: var(--wm-border-color-light);
}
.login--card .login__card {
@apply flex-none;
}
.login__form {
@apply w-full;
}
.login__form-head {
@apply mb-lg;
}
.login__form-title {
@apply text-2xl font-semibold text-text-primary;
}
.login__form-subtitle {
@apply mt-xs text-sm text-text-secondary;
}
/* 填充式输入框 */
.login__form :deep(.el-form-item) {
margin-bottom: 22px;
}
.login__form :deep(.el-input__wrapper) {
@apply rounded-base transition-shadow duration-200;
background-color: var(--wm-bg-color-page);
box-shadow: none;
}
.login__form :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px var(--wm-border-color-light) inset;
}
.login__form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--wm-color-primary) inset;
}
.login__captcha {
@apply flex w-full gap-sm;
}
.login__captcha-code {
@apply flex w-28 flex-shrink-0 cursor-pointer select-none items-center justify-center rounded-base font-bold text-lg tracking-[4px];
background: var(--wm-bg-color-page);
color: var(--wm-color-primary);
}
.login__row {
@apply flex items-center justify-between;
}
.login__submit {
@apply mt-lg h-11 w-full border-none text-base rounded-full tracking-[2px];
background: linear-gradient(90deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
&:hover {
@apply opacity-90;
}
}
.login__register {
@apply mt-sm text-center text-sm text-text-secondary;
}
.login__error {
@apply mt-sm text-center text-sm;
color: var(--el-color-danger);
}
/* ---------- 租户选择 ---------- */
.login__tenants {
@apply flex flex-col gap-2;
}
.login__tenant-item {
@apply flex cursor-pointer items-center justify-between rounded-base border border-solid px-4 py-3 transition-colors;
border-color: var(--wm-border-color-light);
&:hover {
@apply border-primary;
background: var(--el-color-primary-light-9);
}
}
.login__tenant-code {
@apply text-xs text-text-secondary;
}
/* ---------- 卡片布局(card) ---------- */
.login__panel-head {
@apply mb-lg text-center;
}
.login__panel-logo {
@apply mx-auto h-16 w-16 object-contain;
}
.login__panel-title {
@apply mt-md text-2xl font-semibold text-text-primary;
}
.login__panel-subtitle {
@apply mt-xs text-base text-text-secondary;
}
.login__panel-copyright {
@apply mt-xl text-center text-xs text-text-secondary;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="log-page">
<el-card class="log-page__search" shadow="never">
<ty-search-form :schema="searchSchema" :model-value="searchQuery" @search="handleSearch" @reset="handleReset" />
</el-card>
<el-card class="log-page__table" shadow="never">
<div class="log-page__table-body">
<ty-table
:columns="columns"
:data="list"
:loading="loading"
:total="total"
v-model:page="page"
v-model:page-size="pageSize"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { JsonSchema, TableColumn } from '@/types/crud'
import { useRequest } from '@/composables/useRequest'
import { listAuditLogs } from '@/api/audit-log'
const columns: TableColumn[] = [
{ prop: 'username', label: '操作人', width: 100 },
{ prop: 'module', label: '模块', width: 100 },
{ prop: 'action', label: '动作', width: 100 },
{ prop: 'method', label: '方法', width: 80, align: 'center' },
{ prop: 'path', label: '路径', minWidth: 180 },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'login_status' },
{ prop: 'ip', label: 'IP', width: 130 },
{ prop: 'durationMs', label: '耗时(ms)', width: 90, align: 'center' },
{ prop: 'createdAt', label: '时间', width: 160, type: 'datetime' },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
username: { type: 'string', title: '操作人', placeholder: '请输入操作人' },
module: { type: 'string', title: '模块', placeholder: '请输入模块' },
status: {
type: 'string',
title: '状态',
dictCode: 'login_status',
placeholder: '全部',
},
},
}
const page = ref(1)
const pageSize = ref(10)
const searchQuery = reactive({ username: '', module: '', status: '' })
const appliedQuery = reactive({ username: '', module: '', status: '' })
const { data, loading, run } = useRequest(
(signal: AbortSignal) => listAuditLogs({ page: page.value, pageSize: pageSize.value, ...appliedQuery }, signal),
{ manual: true },
)
const list = computed(() => data.value?.list ?? [])
const total = computed(() => data.value?.total ?? 0)
function handleSearch(): void {
appliedQuery.username = searchQuery.username
appliedQuery.module = searchQuery.module
appliedQuery.status = searchQuery.status
page.value = 1
run()
}
function handleReset(): void {
appliedQuery.username = ''
appliedQuery.module = ''
appliedQuery.status = ''
page.value = 1
run()
}
onMounted(run)
watch([page, pageSize], () => {
run()
})
</script>
<style scoped lang="scss">
.log-page {
@apply flex h-full flex-col;
}
.log-page__search {
@apply mb-md flex-shrink-0;
}
.log-page__table {
@apply flex min-h-0 flex-1 flex-col;
:deep(.el-card__body) {
@apply flex min-h-0 flex-1 flex-col;
}
}
.log-page__table-body {
@apply min-h-0 flex-1;
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<div class="log-page">
<el-card class="log-page__search" shadow="never">
<ty-search-form :schema="searchSchema" :model-value="searchQuery" @search="handleSearch" @reset="handleReset" />
</el-card>
<el-card class="log-page__table" shadow="never">
<div class="log-page__table-body">
<ty-table
:columns="columns"
:data="list"
:loading="loading"
:total="total"
v-model:page="page"
v-model:page-size="pageSize"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { JsonSchema, TableColumn } from '@/types/crud'
import { useRequest } from '@/composables/useRequest'
import { listLoginLogs } from '@/api/login-log'
const columns: TableColumn[] = [
{ prop: 'username', label: '用户名', width: 120 },
{ prop: 'ip', label: 'IP', width: 130 },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'login_status' },
{ prop: 'message', label: '信息', minWidth: 160 },
{ prop: 'userAgent', label: 'UA', minWidth: 200 },
{ prop: 'createdAt', label: '登录时间', width: 160, type: 'datetime' },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
username: { type: 'string', title: '用户名', placeholder: '请输入用户名' },
status: {
type: 'string',
title: '状态',
dictCode: 'login_status',
placeholder: '全部',
},
},
}
const page = ref(1)
const pageSize = ref(10)
const searchQuery = reactive({ username: '', status: '' })
const appliedQuery = reactive({ username: '', status: '' })
const { data, loading, run } = useRequest(
(signal: AbortSignal) => listLoginLogs({ page: page.value, pageSize: pageSize.value, ...appliedQuery }, signal),
{ manual: true },
)
const list = computed(() => data.value?.list ?? [])
const total = computed(() => data.value?.total ?? 0)
function handleSearch(): void {
appliedQuery.username = searchQuery.username
appliedQuery.status = searchQuery.status
page.value = 1
run()
}
function handleReset(): void {
appliedQuery.username = ''
appliedQuery.status = ''
page.value = 1
run()
}
onMounted(run)
watch([page, pageSize], () => {
run()
})
</script>
<style scoped lang="scss">
.log-page {
@apply flex h-full flex-col;
}
.log-page__search {
@apply mb-md flex-shrink-0;
}
.log-page__table {
@apply flex min-h-0 flex-1 flex-col;
:deep(.el-card__body) {
@apply flex min-h-0 flex-1 flex-col;
}
}
.log-page__table-body {
@apply min-h-0 flex-1;
}
</style>

186
src/pages/register.vue Normal file
View File

@ -0,0 +1,186 @@
<route lang="yaml">
meta:
layout: 'blank'
requiresAuth: false
</route>
<template>
<div class="auth">
<div class="auth__card">
<div class="auth__head">
<img v-if="config.logo" :src="config.logo" class="auth__logo" alt="logo" />
<h1 class="auth__title">注册账号</h1>
<p class="auth__subtitle">创建一个新账号</p>
</div>
<el-form ref="formRef" class="auth__form" :model="model" :rules="rules" size="large" @keyup.enter="onSubmit">
<el-form-item prop="username">
<el-input v-model="model.username" placeholder="用户名">
<template #prefix><icon type="iconify" icon="mdi:account" /></template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="model.password" type="password" show-password placeholder="密码">
<template #prefix><icon type="iconify" icon="mdi:lock" /></template>
</el-input>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="model.confirmPassword" type="password" show-password placeholder="确认密码">
<template #prefix><icon type="iconify" icon="mdi:lock-check" /></template>
</el-input>
</el-form-item>
<el-form-item prop="email">
<el-input v-model="model.email" placeholder="邮箱">
<template #prefix><icon type="iconify" icon="mdi:email-outline" /></template>
</el-input>
</el-form-item>
<el-form-item prop="phone">
<el-input v-model="model.phone" placeholder="手机号(可选)">
<template #prefix><icon type="iconify" icon="mdi:cellphone" /></template>
</el-input>
</el-form-item>
<el-button type="primary" size="large" class="auth__submit" :loading="loading" @click="onSubmit">
注 册
</el-button>
</el-form>
<div class="auth__footer">
已有账号?
<el-link type="primary" :underline="false" @click="router.push('/login')">返回登录</el-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAppStore } from '@/stores/modules/app'
import { defaultLoginConfig } from '@/config/login'
import { applyTheme } from '@/utils/theme'
import { register } from '@/api/auth'
const router = useRouter()
const app = useAppStore()
const config = defaultLoginConfig
// 注册页不在布局组件内,手动应用主题
applyTheme(app.themeColor, app.isDark)
const formRef = ref<FormInstance>()
const loading = ref(false)
const model = reactive({ username: '', password: '', confirmPassword: '', email: '', phone: '' })
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value !== model.password) callback(new Error('两次输入的密码不一致'))
else callback()
},
trigger: 'blur',
},
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
}
async function onSubmit(): Promise<void> {
if (loading.value) return
try {
await formRef.value?.validate()
} catch {
return
}
loading.value = true
try {
await register({
tenantCode: config.tenantCode,
username: model.username,
password: model.password,
email: model.email,
phone: model.phone || undefined,
})
ElMessage.success('注册成功,请登录')
router.push({ path: '/login', query: { username: model.username } })
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '注册失败')
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
.auth {
@apply flex min-h-screen items-center justify-center;
background: var(--wm-bg-color-page);
}
.auth__card {
@apply rounded-lg border border-solid p-2xl shadow-md w-[440px] max-w-[92vw];
background: var(--wm-bg-color-base);
border-color: var(--wm-border-color-light);
}
.auth__head {
@apply mb-lg text-center;
}
.auth__logo {
@apply mx-auto h-16 w-16 object-contain;
}
.auth__title {
@apply mt-md text-2xl font-semibold text-text-primary;
}
.auth__subtitle {
@apply mt-xs text-sm text-text-secondary;
}
.auth__form {
@apply w-full;
}
.auth__form :deep(.el-form-item) {
margin-bottom: 22px;
}
.auth__form :deep(.el-input__wrapper) {
@apply rounded-base transition-shadow duration-200;
background-color: var(--wm-bg-color-page);
box-shadow: none;
}
.auth__form :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px var(--wm-border-color-light) inset;
}
.auth__form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--wm-color-primary) inset;
}
.auth__submit {
@apply mt-lg h-11 w-full border-none text-base rounded-full tracking-[2px];
background: linear-gradient(90deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
&:hover {
@apply opacity-90;
}
}
.auth__footer {
@apply mt-lg text-center text-sm text-text-secondary;
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<ty-crud
entity-name="配置"
:columns="columns"
:search-schema="searchSchema"
:form-schema="formSchema"
:list="list"
:create="create"
:update="update"
:remove="remove"
id-key="id"
name-key="key"
:edit-readonly-keys="['key']"
/>
</template>
<script setup lang="ts">
import type { JsonSchema, PageQuery, TableColumn } from '@/types/crud'
import { createConfig, deleteConfig, listConfigs, updateConfig, type ConfigListQuery } from '@/api/config'
const columns: TableColumn[] = [
{ prop: 'key', label: '键名', minWidth: 160 },
{ prop: 'value', label: '值', minWidth: 220 },
{ prop: 'remark', label: '备注', minWidth: 160 },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
key: { type: 'string', title: '键名', placeholder: '请输入键名' },
value: { type: 'string', title: '值', placeholder: '请输入值' },
},
}
const formSchema: JsonSchema = {
type: 'object',
properties: {
key: { type: 'string', title: '键名', placeholder: '请输入键名', minLength: 1 },
value: { type: 'string', title: '值', format: 'textarea', placeholder: '请输入值' },
remark: { type: 'string', title: '备注', placeholder: '请输入备注' },
},
required: ['key'],
}
function toListQuery(query: PageQuery): ConfigListQuery {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
return {
page: query.page,
pageSize: query.pageSize,
key: str(query.key),
value: str(query.value),
}
}
const list = (query: PageQuery, signal?: AbortSignal) => listConfigs(toListQuery(query), signal)
const create = (data: Record<string, any>) => createConfig(data)
const update = (id: string, data: Record<string, any>) => updateConfig(id, data)
const remove = (id: string) => deleteConfig(id)
</script>

View File

@ -0,0 +1,345 @@
<template>
<div class="dict-page">
<el-card class="dict-page__types" shadow="never">
<div class="dict-page__type-head">
<el-input v-model="typeKeyword" placeholder="搜索字典类型" clearable class="min-w-0 flex-1" />
<el-button type="primary" @click="openTypeCreate">新增</el-button>
</div>
<div class="dict-page__type-list">
<el-scrollbar class="h-full">
<div
v-for="t in filteredTypes"
:key="t.code"
class="dict-page__type-item"
:class="{ 'is-active': t.code === selectedType?.code }"
@click="selectType(t)"
>
<div class="dict-page__type-item-row">
<span class="dict-page__type-item-name">
{{ t.name }}
<el-tag v-if="t.builtIn" size="small" type="primary" effect="plain">内置</el-tag>
</span>
<div class="dict-page__type-item-actions" @click.stop>
<el-button link type="primary" size="small" @click="openTypeEdit(t)">编辑</el-button>
<el-button v-if="!t.builtIn" link type="danger" size="small" @click="removeType(t)">删除</el-button>
</div>
</div>
<div class="dict-page__type-item-meta">
<span class="dict-page__type-item-code">{{ t.code }}</span>
<el-tag :type="commonStatusMap[t.status]?.type ?? 'info'" size="small">
{{ commonStatusMap[t.status]?.label ?? t.status }}
</el-tag>
</div>
</div>
<el-empty v-if="filteredTypes.length === 0" description="无匹配的字典类型" :image-size="60" />
</el-scrollbar>
</div>
</el-card>
<div class="dict-page__items">
<ty-crud
v-if="selectedType"
:key="selectedType.code"
ref="itemCrudRef"
entity-name="字典项"
:columns="itemColumns"
:search-schema="itemSearchSchema"
:form-schema="itemFormSchema"
:list="listItems"
:create="createItem"
:update="updateItem"
:remove="removeItem"
:actions="itemActions"
:get-edit-readonly-keys="(row) => (row.builtIn ? ['value'] : [])"
id-key="id"
name-key="label"
/>
<div v-else class="dict-page__items-empty">
<el-empty description="请选择或新增字典类型" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
import TyForm from '@/components/ty-form.vue'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import { useDict } from '@/composables/useDict'
import { useDictStore } from '@/stores/modules/dict'
import {
createDictItem,
createDictType,
deleteDictItem,
deleteDictType,
listDictItems,
listDictTypes,
updateDictItem,
updateDictType,
type DictType,
} from '@/api/dict'
const { tagMap: commonStatusMap } = useDict('common_status')
const dictStore = useDictStore()
/** 标签色中文名(UI 调色板,非业务字典)。 */
const COLOR_LABELS: Record<string, string> = {
primary: '主色',
success: '成功',
warning: '警告',
danger: '危险',
info: '信息',
}
// ---- 字典类型(左) ----
const { data: typePage, refresh: refreshTypes } = useRequest((signal: AbortSignal) => listDictTypes(1, 1000, signal))
const types = computed<DictType[]>(() => typePage.value?.list ?? [])
const typeKeyword = ref('')
const selectedType = ref<DictType | null>(null)
const filteredTypes = computed(() => {
const kw = typeKeyword.value.trim().toLowerCase()
if (!kw) return types.value
return types.value.filter((t) => t.name.toLowerCase().includes(kw) || t.code.toLowerCase().includes(kw))
})
// 类型加载后默认选中第一个;当前选中被删后回退到第一个
watch(types, (list) => {
if (!selectedType.value) selectedType.value = list[0] ?? null
else if (!list.some((t) => t.id === selectedType.value!.id)) selectedType.value = list[0] ?? null
})
function selectType(t: DictType): void {
selectedType.value = t
}
// ---- 字典类型表单 ----
const typeFormSchema: JsonSchema = {
type: 'object',
properties: {
code: { type: 'string', title: '字典编码', placeholder: '如 sys_status', minLength: 2 },
name: { type: 'string', title: '字典名称', placeholder: '如 系统状态' },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
default: 'enabled',
showOn: ['edit'],
},
remark: { type: 'string', title: '备注', format: 'textarea', placeholder: '请输入备注' },
},
required: ['code', 'name'],
}
function buildTypeModel(t?: DictType): Record<string, any> {
return { code: t?.code ?? '', name: t?.name ?? '', status: t?.status ?? 'enabled', remark: t?.remark ?? '' }
}
async function openTypeCreate(): Promise<void> {
const data = await showDialog({
component: TyForm,
props: { schema: typeFormSchema, modelValue: buildTypeModel(), mode: 'create' },
title: '新增字典类型',
width: '480px',
})
if (data == null) return
await createDictType({ code: data.code, name: data.name, remark: data.remark })
ElMessage.success('新增成功')
await refreshTypes()
await dictStore.fetch(true)
selectedType.value = types.value.find((t) => t.code === data.code) ?? selectedType.value
}
async function openTypeEdit(t: DictType): Promise<void> {
const data = await showDialog({
component: TyForm,
props: { schema: typeFormSchema, modelValue: buildTypeModel(t), mode: 'edit', readonlyKeys: ['code'] },
title: '编辑字典类型',
width: '480px',
})
if (data == null) return
await updateDictType(t.id, { name: data.name, status: data.status, remark: data.remark })
ElMessage.success('更新成功')
await refreshTypes()
await dictStore.fetch(true)
}
async function removeType(t: DictType): Promise<void> {
await ElMessageBox.confirm(`确认删除字典类型「${t.name}」?`, '提示', { type: 'warning' })
await deleteDictType(t.id)
ElMessage.success('删除成功')
await refreshTypes()
await dictStore.fetch(true)
}
// ---- 字典项(右) ----
const itemColumns: TableColumn[] = [
{ prop: 'label', label: '标签', minWidth: 120 },
{ prop: 'value', label: '值', minWidth: 120 },
{ prop: 'colorType', label: '标签色', width: 90, align: 'center', formatter: (v) => (v ? COLOR_LABELS[String(v)] ?? String(v) : '—') },
{ prop: 'builtIn', label: '内置', width: 70, align: 'center', formatter: (v) => (v ? '内置' : '') },
{ prop: 'sort', label: '排序', width: 70, align: 'center' },
{ prop: 'status', label: '状态', width: 90, align: 'center', type: 'tag', dictCode: 'common_status' },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
const itemSearchSchema: JsonSchema = {
type: 'object',
properties: {
label: { type: 'string', title: '标签', placeholder: '请输入标签' },
value: { type: 'string', title: '值', placeholder: '请输入值' },
},
}
const itemFormSchema: JsonSchema = {
type: 'object',
properties: {
label: { type: 'string', title: '标签', placeholder: '如 启用' },
value: { type: 'string', title: '值', placeholder: '如 enabled' },
colorType: {
type: 'string',
title: '标签色',
enum: ['primary', 'success', 'warning', 'danger', 'info'],
enumNames: ['主色', '成功', '警告', '危险', '信息'],
placeholder: '默认灰',
},
sort: { type: 'integer', title: '排序', minimum: 0, default: 0 },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
default: 'enabled',
showOn: ['edit'],
},
},
required: ['label', 'value'],
}
const listItems = async (query: PageQuery, signal?: AbortSignal) => {
const code = selectedType.value?.code
if (!code) return { list: [], total: 0, page: query.page, pageSize: query.pageSize }
const items = await listDictItems(code, signal)
const filtered = items.filter((i) => {
if (query.label && !i.label.includes(String(query.label))) return false
if (query.value && !i.value.includes(String(query.value))) return false
return true
})
const start = (query.page - 1) * query.pageSize
return {
list: filtered.slice(start, start + query.pageSize) as unknown as Record<string, any>[],
total: filtered.length,
page: query.page,
pageSize: query.pageSize,
}
}
const createItem = async (data: Record<string, any>) => {
await createDictItem({
dictTypeCode: selectedType.value?.code ?? '',
label: data.label,
value: data.value,
sort: data.sort ?? 0,
colorType: data.colorType || undefined,
})
await dictStore.fetch(true)
}
const updateItem = async (id: string, data: Record<string, any>) => {
await updateDictItem(id, {
label: data.label,
value: data.value,
sort: data.sort ?? 0,
status: data.status,
colorType: data.colorType || undefined,
})
await dictStore.fetch(true)
}
const removeItem = (id: string) => deleteDictItem(id)
const itemCrudRef = ref()
const itemActions: CrudAction[] = [
'edit',
{ label: '删除', type: 'danger', onClick: (row) => removeWithConfirm(row), show: (row) => !row.builtIn },
]
async function removeWithConfirm(row: Record<string, any>): Promise<void> {
await ElMessageBox.confirm(`确认删除字典项「${row.label}」?`, '提示', { type: 'warning' })
await deleteDictItem(String(row.id))
ElMessage.success('删除成功')
itemCrudRef.value?.reload()
await dictStore.fetch(true)
}
</script>
<style scoped lang="scss">
.dict-page {
@apply flex h-full gap-md;
}
.dict-page__types {
@apply w-[280px] flex-shrink-0;
:deep(.el-card__body) {
@apply flex h-full flex-col;
}
}
.dict-page__type-head {
@apply mb-sm flex flex-shrink-0 gap-sm;
}
.dict-page__type-list {
@apply min-h-0 flex-1;
}
.dict-page__type-item {
@apply mb-xs cursor-pointer rounded-sm border border-transparent px-sm py-xs transition-colors;
&:hover {
@apply bg-bg-page;
}
&.is-active {
@apply border-primary bg-primary-light-7;
}
}
.dict-page__type-item-row {
@apply flex items-center justify-between;
}
.dict-page__type-item-name {
@apply truncate font-medium text-text-primary;
}
.dict-page__type-item-actions {
@apply hidden flex-shrink-0;
}
.dict-page__type-item:hover .dict-page__type-item-actions {
@apply flex items-center gap-xs;
}
.dict-page__type-item-meta {
@apply mt-xs flex items-center justify-between;
}
.dict-page__type-item-code {
@apply truncate text-xs text-text-secondary;
}
.dict-page__items {
@apply min-w-0 flex-1;
}
.dict-page__items-empty {
@apply flex h-full items-center justify-center;
}
</style>

View File

@ -0,0 +1,278 @@
<template>
<div class="org-page">
<el-card class="org-page__search" shadow="never">
<ty-search-form :schema="searchSchema" :model-value="searchQuery" @search="handleSearch" @reset="handleReset" />
</el-card>
<el-card class="org-page__table" shadow="never">
<div class="org-page__toolbar">
<div class="org-page__toolbar-left">
<el-button type="primary" @click="openCreate">新增组织</el-button>
</div>
<div class="org-page__toolbar-right">
<el-button type="danger" :disabled="selectedRows.length === 0" @click="batchDelete(selectedRows)">
批量删除
</el-button>
</div>
</div>
<div class="org-page__table-body">
<ty-table
:columns="columns"
:data="displayTree"
selection
row-key="code"
:tree-props="{ children: 'children' }"
default-expand-all
:actions-width="180"
@selection-change="onSelectionChange"
>
<template #actions="{ row }">
<el-button link type="primary" @click="openCreateChild(row)">新增子</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</ty-table>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { JsonSchema, TableColumn } from '@/types/crud'
import TyForm from '@/components/ty-form.vue'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
import { createOrg, deleteOrg, listOrgs, updateOrg, type Org } from '@/api/org'
type OrgNode = TreeNode<Org>
const { data: orgList, refresh: refreshOrgs } = useRequest((signal: AbortSignal) => listOrgs(signal))
const orgs = computed<Org[]>(() => orgList.value ?? [])
const columns: TableColumn[] = [
{ prop: 'name', label: '组织名称', minWidth: 220 },
{ prop: 'code', label: '组织编码', minWidth: 120 },
{ prop: 'leader', label: '负责人', width: 100 },
{ prop: 'sort', label: '排序', width: 70, align: 'center' },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
name: { type: 'string', title: '组织名称', placeholder: '请输入名称' },
code: { type: 'string', title: '组织编码', placeholder: '请输入编码' },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
placeholder: '全部',
},
},
}
const searchQuery = reactive({ name: '', code: '', status: '' })
const appliedQuery = reactive({ name: '', code: '', status: '' })
const displayTree = computed<OrgNode[]>(() =>
buildTree(
filterTree(orgs.value, (r) => {
const name = appliedQuery.name.trim()
const code = appliedQuery.code.trim()
const status = appliedQuery.status
return (!name || r.name.includes(name)) && (!code || r.code.includes(code)) && (!status || r.status === status)
}),
),
)
function handleSearch(): void {
appliedQuery.name = searchQuery.name
appliedQuery.code = searchQuery.code
appliedQuery.status = searchQuery.status
}
function handleReset(): void {
appliedQuery.name = ''
appliedQuery.code = ''
appliedQuery.status = ''
}
// ---- 表单 ----
function treeSelectData(excludeCode?: string): OrgNode[] {
const tree = buildTree(orgs.value)
if (!excludeCode) return tree
const prune = (nodes: OrgNode[]): OrgNode[] =>
nodes.filter((n) => n.code !== excludeCode).map((n) => ({ ...n, children: prune(n.children) }))
return prune(tree)
}
function orgFormSchema(excludeCode?: string): JsonSchema {
return {
type: 'object',
properties: {
parentCode: {
type: 'string',
title: '上级组织',
format: 'tree-select',
treeData: treeSelectData(excludeCode),
treeProps: { label: 'name', value: 'code', children: 'children' },
placeholder: '不选则为顶级组织',
},
code: { type: 'string', title: '组织编码', placeholder: '如 rd', minLength: 2 },
name: { type: 'string', title: '组织名称', placeholder: '如 研发部' },
leader: { type: 'string', title: '负责人', placeholder: '请输入负责人' },
sort: { type: 'integer', title: '排序', minimum: 0, default: 0 },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
default: 'enabled',
showOn: ['edit'],
},
},
required: ['code', 'name'],
}
}
function buildOrgModel(o?: Org): Record<string, any> {
return {
parentCode: o?.parentCode ?? '',
code: o?.code ?? '',
name: o?.name ?? '',
leader: o?.leader ?? '',
sort: o?.sort ?? 0,
status: o?.status ?? 'enabled',
}
}
async function openCreate(): Promise<void> {
const data = await showDialog({
component: TyForm,
props: { schema: orgFormSchema(), modelValue: buildOrgModel(), mode: 'create' },
title: '新增组织',
width: '480px',
})
if (data == null) return
await createOrg({
code: data.code,
name: data.name,
parentCode: data.parentCode || null,
leader: data.leader,
sort: data.sort ?? 0,
})
ElMessage.success('新增成功')
await refreshOrgs()
}
async function openCreateChild(parent: Record<string, any>): Promise<void> {
const model = buildOrgModel()
model.parentCode = parent.code
const data = await showDialog({
component: TyForm,
props: { schema: orgFormSchema(), modelValue: model, mode: 'create' },
title: `新增子组织(${parent.name})`,
width: '480px',
})
if (data == null) return
await createOrg({
code: data.code,
name: data.name,
parentCode: data.parentCode || parent.code,
leader: data.leader,
sort: data.sort ?? 0,
})
ElMessage.success('新增成功')
await refreshOrgs()
}
async function openEdit(row: Record<string, any>): Promise<void> {
const o = row as Org
const data = await showDialog({
component: TyForm,
props: {
schema: orgFormSchema(o.code),
modelValue: buildOrgModel(o),
mode: 'edit',
readonlyKeys: ['code'],
},
title: '编辑组织',
width: '480px',
})
if (data == null) return
await updateOrg(o.id, {
parentCode: data.parentCode || null,
name: data.name,
leader: data.leader,
sort: data.sort ?? 0,
status: data.status,
})
ElMessage.success('更新成功')
await refreshOrgs()
}
async function handleDelete(row: Record<string, any>): Promise<void> {
const o = row as Org
await ElMessageBox.confirm(`确认删除组织「${o.name}」及其所有下级组织?`, '提示', { type: 'warning' })
const codes = new Set([o.code, ...collectDescendants(orgs.value, o.code)])
const targets = orgs.value.filter((x) => codes.has(x.code))
for (const t of targets) await deleteOrg(t.id)
ElMessage.success('删除成功')
await refreshOrgs()
}
const selectedRows = ref<Record<string, any>[]>([])
function onSelectionChange(rows: Record<string, any>[]): void {
selectedRows.value = rows
}
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个组织及其下级?`, '提示', { type: 'warning' })
const codes = new Set<string>()
for (const r of rows) {
codes.add(String(r.code))
for (const c of collectDescendants(orgs.value, String(r.code))) codes.add(c)
}
const targets = orgs.value.filter((o) => codes.has(o.code))
for (const t of targets) await deleteOrg(t.id)
selectedRows.value = []
ElMessage.success('删除成功')
await refreshOrgs()
}
</script>
<style scoped lang="scss">
.org-page {
@apply flex h-full flex-col;
}
.org-page__search {
@apply mb-md flex-shrink-0;
}
.org-page__table {
@apply flex min-h-0 flex-1 flex-col;
:deep(.el-card__body) {
@apply flex min-h-0 flex-1 flex-col;
}
}
.org-page__toolbar {
@apply mb-md flex flex-shrink-0 items-center justify-between;
}
.org-page__toolbar-left,
.org-page__toolbar-right {
@apply flex items-center gap-2;
}
.org-page__table-body {
@apply min-h-0 flex-1;
}
</style>

View File

@ -0,0 +1,328 @@
<template>
<div class="res-page">
<el-card class="res-page__search" shadow="never">
<ty-search-form :schema="searchSchema" :model-value="searchQuery" @search="handleSearch" @reset="handleReset" />
</el-card>
<el-card class="res-page__table" shadow="never">
<div class="res-page__toolbar">
<div class="res-page__toolbar-left">
<el-button type="primary" @click="openCreate">新增资源</el-button>
</div>
<div class="res-page__toolbar-right">
<el-button type="danger" :disabled="selectedRows.length === 0" @click="batchDelete(selectedRows)">
批量删除
</el-button>
</div>
</div>
<div class="res-page__table-body">
<ty-table
:columns="columns"
:data="displayTree"
selection
row-key="code"
:tree-props="{ children: 'children' }"
default-expand-all
:actions-width="180"
@selection-change="onSelectionChange"
>
<template #actions="{ row }">
<el-button link type="primary" @click="openCreateChild(row)">新增子</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</ty-table>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { JsonSchema, TableColumn } from '@/types/crud'
import TyForm from '@/components/ty-form.vue'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
import { createResource, deleteResource, listResources, updateResource, type Resource, type ResourceUpsertData } from '@/api/resource'
type ResourceNode = TreeNode<Resource>
const { data: resourceList, refresh: refreshResources } = useRequest((signal: AbortSignal) => listResources(signal))
const resources = computed<Resource[]>(() => resourceList.value ?? [])
function pathDisplay(_value: unknown, row: Record<string, unknown>): string {
const type = row.type
if (type === 'api') {
const method = row.method as string | undefined
const apiPath = row.apiPath as string | undefined
return `${method ? `${method} ` : ''}${apiPath ?? ''}`.trim() || '-'
}
if (type === 'menu') return (row.path as string) || '-'
return '-'
}
const columns: TableColumn[] = [
{ prop: 'name', label: '资源名称', minWidth: 200 },
{ prop: 'type', label: '类型', width: 80, align: 'center', type: 'tag', dictCode: 'resource_type' },
{ prop: 'code', label: '资源编码', minWidth: 160 },
{ prop: 'path', label: '路由 / 接口', minWidth: 180, formatter: pathDisplay },
{ prop: 'sort', label: '排序', width: 70, align: 'center' },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
name: { type: 'string', title: '资源名称', placeholder: '请输入名称' },
code: { type: 'string', title: '资源编码', placeholder: '请输入编码' },
type: {
type: 'string',
title: '类型',
dictCode: 'resource_type',
placeholder: '全部',
},
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
placeholder: '全部',
},
},
}
const searchQuery = reactive({ name: '', code: '', type: '', status: '' })
const appliedQuery = reactive({ name: '', code: '', type: '', status: '' })
const displayTree = computed<ResourceNode[]>(() =>
buildTree(
filterTree(resources.value, (r) => {
const name = appliedQuery.name.trim()
const code = appliedQuery.code.trim()
const type = appliedQuery.type
const status = appliedQuery.status
return (
(!name || r.name.includes(name)) &&
(!code || r.code.includes(code)) &&
(!type || r.type === type) &&
(!status || r.status === status)
)
}),
),
)
function handleSearch(): void {
appliedQuery.name = searchQuery.name
appliedQuery.code = searchQuery.code
appliedQuery.type = searchQuery.type
appliedQuery.status = searchQuery.status
}
function handleReset(): void {
appliedQuery.name = ''
appliedQuery.code = ''
appliedQuery.type = ''
appliedQuery.status = ''
}
// ---- 表单 ----
function treeSelectData(excludeCode?: string): ResourceNode[] {
const tree = buildTree(resources.value)
if (!excludeCode) return tree
const prune = (nodes: ResourceNode[]): ResourceNode[] =>
nodes.filter((n) => n.code !== excludeCode).map((n) => ({ ...n, children: prune(n.children) }))
return prune(tree)
}
function resourceFormSchema(excludeCode?: string): JsonSchema {
return {
type: 'object',
properties: {
parentCode: {
type: 'string',
title: '上级资源',
format: 'tree-select',
treeData: treeSelectData(excludeCode),
treeProps: { label: 'name', value: 'code', children: 'children' },
placeholder: '不选则为顶级资源',
},
type: {
type: 'string',
title: '资源类型',
dictCode: 'resource_type',
default: 'menu',
},
code: { type: 'string', title: '资源编码', placeholder: '如 system:user:create' },
name: { type: 'string', title: '资源名称', placeholder: '如 用户管理' },
path: { type: 'string', title: '路由路径', placeholder: '如 /system/user', when: { field: 'type', in: ['menu'] } },
component: { type: 'string', title: '组件路径', placeholder: '如 system/user/index', when: { field: 'type', in: ['menu'] } },
icon: { type: 'string', title: '图标', placeholder: '如 mdi:account-group', when: { field: 'type', in: ['menu'] } },
method: {
type: 'string',
title: '请求方法',
enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
placeholder: '请选择',
when: { field: 'type', equals: 'api' },
},
apiPath: { type: 'string', title: '接口路径', placeholder: '如 /users', when: { field: 'type', equals: 'api' } },
sort: { type: 'integer', title: '排序', minimum: 0, default: 0 },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
default: 'enabled',
showOn: ['edit'],
},
},
required: ['code', 'name', 'type'],
}
}
function buildResourceModel(r?: Resource): Record<string, any> {
return {
parentCode: r?.parentCode ?? '',
type: r?.type ?? 'menu',
code: r?.code ?? '',
name: r?.name ?? '',
path: r?.path ?? '',
component: r?.component ?? '',
icon: r?.icon ?? '',
method: r?.method ?? '',
apiPath: r?.apiPath ?? '',
sort: r?.sort ?? 0,
status: r?.status ?? 'enabled',
}
}
/** 按类型归一化表单数据:菜单只带 path/component/icon,接口只带 method/apiPath。 */
function toUpsertData(data: Record<string, any>): ResourceUpsertData {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
const isMenu = data.type === 'menu'
const isApi = data.type === 'api'
return {
type: data.type,
code: str(data.code),
name: str(data.name),
parentCode: str(data.parentCode) ?? null,
path: isMenu ? str(data.path) : undefined,
component: isMenu ? str(data.component) : undefined,
icon: isMenu ? str(data.icon) : undefined,
method: isApi ? str(data.method) : undefined,
apiPath: isApi ? str(data.apiPath) : undefined,
sort: data.sort ?? 0,
status: str(data.status),
}
}
async function openCreate(): Promise<void> {
const data = await showDialog({
component: TyForm,
props: { schema: resourceFormSchema(), modelValue: buildResourceModel(), mode: 'create' },
title: '新增资源',
width: '520px',
})
if (data == null) return
await createResource(toUpsertData(data))
ElMessage.success('新增成功')
await refreshResources()
}
async function openCreateChild(parent: Record<string, any>): Promise<void> {
const model = buildResourceModel()
model.parentCode = parent.code
const data = await showDialog({
component: TyForm,
props: { schema: resourceFormSchema(), modelValue: model, mode: 'create' },
title: `新增子资源(${parent.name})`,
width: '520px',
})
if (data == null) return
const upsert = toUpsertData(data)
upsert.parentCode = upsert.parentCode || parent.code
await createResource(upsert)
ElMessage.success('新增成功')
await refreshResources()
}
async function openEdit(row: Record<string, any>): Promise<void> {
const o = row as Resource
const data = await showDialog({
component: TyForm,
props: {
schema: resourceFormSchema(o.code),
modelValue: buildResourceModel(o),
mode: 'edit',
readonlyKeys: ['code', 'type'],
},
title: '编辑资源',
width: '520px',
})
if (data == null) return
await updateResource(o.id, toUpsertData(data))
ElMessage.success('更新成功')
await refreshResources()
}
async function handleDelete(row: Record<string, any>): Promise<void> {
const o = row as Resource
await ElMessageBox.confirm(`确认删除资源「${o.name}」及其所有下级资源?`, '提示', { type: 'warning' })
const codes = new Set([o.code, ...collectDescendants(resources.value, o.code)])
const targets = resources.value.filter((x) => codes.has(x.code))
for (const t of targets) await deleteResource(t.id)
ElMessage.success('删除成功')
await refreshResources()
}
const selectedRows = ref<Record<string, any>[]>([])
function onSelectionChange(rows: Record<string, any>[]): void {
selectedRows.value = rows
}
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个资源及其下级?`, '提示', { type: 'warning' })
const codes = new Set<string>()
for (const r of rows) {
codes.add(String(r.code))
for (const c of collectDescendants(resources.value, String(r.code))) codes.add(c)
}
const targets = resources.value.filter((o) => codes.has(o.code))
for (const t of targets) await deleteResource(t.id)
selectedRows.value = []
ElMessage.success('删除成功')
await refreshResources()
}
</script>
<style scoped lang="scss">
.res-page {
@apply flex h-full flex-col;
}
.res-page__search {
@apply mb-md flex-shrink-0;
}
.res-page__table {
@apply flex min-h-0 flex-1 flex-col;
:deep(.el-card__body) {
@apply flex min-h-0 flex-1 flex-col;
}
}
.res-page__toolbar {
@apply mb-md flex flex-shrink-0 items-center justify-between;
}
.res-page__toolbar-left,
.res-page__toolbar-right {
@apply flex items-center gap-2;
}
.res-page__table-body {
@apply min-h-0 flex-1;
}
</style>

View File

@ -0,0 +1,59 @@
<template>
<el-tree
v-if="ready"
ref="treeRef"
class="assign-tree"
:data="tree"
node-key="code"
show-checkbox
:props="{ label: 'name', children: 'children' }"
default-expand-all
/>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRequest } from '@/composables/useRequest'
import { listResources } from '@/api/resource'
import { listAssignedResources } from '@/api/role'
import { buildTree, type TreeNode } from '@/utils/tree'
import type { Resource } from '@/api/resource'
defineOptions({ name: 'AssignResourcesForm' })
const props = defineProps<{ roleCode: string }>()
interface TreeApi {
getCheckedKeys: (leafOnly?: boolean) => (string | number)[]
setCheckedKeys: (keys: (string | number)[], leafOnly?: boolean) => void
}
const treeRef = ref<TreeApi>()
const { data: resourceList } = useRequest((signal: AbortSignal) => listResources(signal))
const { data: assigned } = useRequest((signal: AbortSignal) => listAssignedResources(props.roleCode, signal))
const tree = computed(() => buildTree(resourceList.value ?? []) as TreeNode<Resource>[])
const ready = computed(() => resourceList.value !== undefined && assigned.value !== undefined)
// 回显:仅叶子节点勾选,父节点由叶子自动推导,避免 default-checked-keys 级联全选
watch(ready, async (r) => {
if (r) {
await nextTick()
treeRef.value?.setCheckedKeys(assigned.value ?? [], true)
}
})
function submit(): { resourceCodes: string[] } {
// 只保存叶子节点:半选父节点由叶子推导,避免回显级联造成权限放大
const checked = treeRef.value?.getCheckedKeys(true) ?? []
return { resourceCodes: [...new Set(checked.map(String))] }
}
defineExpose({ submit })
</script>
<style scoped lang="scss">
.assign-tree {
@apply max-h-[420px] overflow-y-auto;
}
</style>

View File

@ -0,0 +1,106 @@
<template>
<ty-crud
entity-name="角色"
:columns="columns"
:search-schema="searchSchema"
:form-schema="formSchema"
:list="list"
:create="create"
:update="update"
:remove="remove"
:actions="actions"
id-key="id"
name-key="name"
:edit-readonly-keys="['code']"
/>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
import { showDialog } from '@/utils/dialog'
import { assignResources, createRole, deleteRole, listRoles, updateRole, type RoleListQuery } from '@/api/role'
import AssignResourcesForm from './assign-resources-form.vue'
const columns: TableColumn[] = [
{ prop: 'name', label: '角色名称', minWidth: 140 },
{ prop: 'code', label: '角色编码', minWidth: 140 },
{ prop: 'dataScope', label: '数据权限', width: 130, align: 'center', type: 'tag', dictCode: 'data_scope' },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' },
{ prop: 'remark', label: '备注', minWidth: 140 },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
name: { type: 'string', title: '角色名称', placeholder: '请输入名称' },
code: { type: 'string', title: '角色编码', placeholder: '请输入编码' },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
placeholder: '全部',
},
},
}
const formSchema: JsonSchema = {
type: 'object',
properties: {
code: { type: 'string', title: '角色编码', placeholder: '如 super_admin', minLength: 2 },
name: { type: 'string', title: '角色名称', placeholder: '如 超级管理员' },
dataScope: {
type: 'string',
title: '数据权限',
dictCode: 'data_scope',
default: 'all',
},
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
default: 'enabled',
showOn: ['edit'],
},
remark: { type: 'string', title: '备注', format: 'textarea', placeholder: '请输入备注' },
},
required: ['code', 'name'],
}
const actions: CrudAction[] = [
'edit',
'delete',
{ label: '分配权限', type: 'warning', onClick: (row) => assignAction(row) },
]
function toListQuery(query: PageQuery): RoleListQuery {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
return {
page: query.page,
pageSize: query.pageSize,
name: str(query.name),
code: str(query.code),
status: str(query.status),
}
}
const list = (query: PageQuery, signal?: AbortSignal) => listRoles(toListQuery(query), signal)
const create = (data: Record<string, any>) =>
createRole({ code: data.code, name: data.name, dataScope: data.dataScope, remark: data.remark })
const update = (id: string, data: Record<string, any>) =>
updateRole(id, { name: data.name, dataScope: data.dataScope, status: data.status, remark: data.remark })
const remove = (id: string) => deleteRole(id)
async function assignAction(row: Record<string, any>): Promise<void> {
const result = await showDialog({
component: AssignResourcesForm,
props: { roleCode: String(row.code) },
title: `分配权限(${row.name})`,
width: '520px',
})
if (result == null) return
await assignResources(String(row.code), result.resourceCodes)
ElMessage.success('权限已保存')
}
</script>

View File

@ -0,0 +1,76 @@
<template>
<ty-crud
entity-name="租户"
:columns="columns"
:search-schema="searchSchema"
:form-schema="formSchema"
:list="list"
:create="create"
:update="update"
:remove="remove"
id-key="id"
name-key="name"
:edit-readonly-keys="['code']"
/>
</template>
<script setup lang="ts">
import type { JsonSchema, PageQuery, TableColumn } from '@/types/crud'
import { createTenant, deleteTenant, listTenants, updateTenant, type TenantListQuery } from '@/api/tenant'
const columns: TableColumn[] = [
{ prop: 'name', label: '租户名称', minWidth: 140 },
{ prop: 'code', label: '租户编码', minWidth: 140 },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' },
{ prop: 'remark', label: '备注', minWidth: 160 },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
name: { type: 'string', title: '租户名称', placeholder: '请输入名称' },
code: { type: 'string', title: '租户编码', placeholder: '请输入编码' },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
placeholder: '全部',
},
},
}
const formSchema: JsonSchema = {
type: 'object',
properties: {
code: { type: 'string', title: '租户编码', placeholder: '如 touyun', minLength: 2 },
name: { type: 'string', title: '租户名称', placeholder: '如 投云' },
status: {
type: 'string',
title: '状态',
dictCode: 'common_status',
default: 'enabled',
},
remark: { type: 'string', title: '备注', format: 'textarea', placeholder: '请输入备注' },
},
required: ['code', 'name'],
}
function toListQuery(query: PageQuery): TenantListQuery {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
return {
page: query.page,
pageSize: query.pageSize,
name: str(query.name),
code: str(query.code),
status: str(query.status),
}
}
const list = (query: PageQuery, signal?: AbortSignal) => listTenants(toListQuery(query), signal)
const create = (data: Record<string, any>) =>
createTenant({ code: data.code, name: data.name, status: data.status, remark: data.remark })
const update = (id: string, data: Record<string, any>) =>
updateTenant(id, { name: data.name, status: data.status, remark: data.remark })
const remove = (id: string) => deleteTenant(id)
</script>

View File

@ -0,0 +1,55 @@
<template>
<div v-loading="!ready" class="assign-roles">
<el-checkbox-group v-model="model.roleCodes" class="assign-roles__group">
<el-checkbox v-for="r in roles" :key="r.code" :value="r.code">
<span>{{ r.name }}</span>
<span class="assign-roles__code">{{ r.code }}</span>
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="ready && roles.length === 0" description="暂无可用角色" :image-size="60" />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useRequest } from '@/composables/useRequest'
import { listRoles } from '@/api/role'
import { listUserRoles } from '@/api/user'
defineOptions({ name: 'AssignRolesForm' })
const props = defineProps<{ userId: string }>()
const { data: rolePage } = useRequest((signal: AbortSignal) => listRoles({ page: 1, pageSize: 1000 }, signal))
const { data: assigned } = useRequest((signal: AbortSignal) => listUserRoles(props.userId, signal))
const roles = computed(() => rolePage.value?.list ?? [])
const ready = computed(() => rolePage.value !== undefined && assigned.value !== undefined)
const model = reactive<{ roleCodes: string[] }>({ roleCodes: [] })
// 回显已分配角色
watch(assigned, (v) => {
if (v) model.roleCodes = [...v]
})
function submit(): { roleCodes: string[] } {
return { roleCodes: model.roleCodes }
}
defineExpose({ submit })
</script>
<style scoped lang="scss">
.assign-roles {
@apply min-h-[120px];
}
.assign-roles__group {
@apply flex flex-col gap-2;
}
.assign-roles__code {
@apply ml-1 text-xs text-text-secondary;
}
</style>

View File

@ -0,0 +1,197 @@
<template>
<ty-crud
ref="crudRef"
entity-name="用户"
selection
:columns="columns"
:search-schema="searchSchema"
:form-schema="formSchema"
:list="list"
:create="create"
:update="update"
:remove="remove"
:actions="actions"
create-permission="user:create"
id-key="id"
name-key="username"
:edit-readonly-keys="['username']"
>
<template #toolbar>
<el-button v-permission="'user:export'" @click="exportUsers">导出</el-button>
</template>
<template #toolbar-right="{ selectedRows }">
<el-button
type="danger"
:disabled="selectedRows.length === 0"
@click="batchDelete(selectedRows)"
>
批量删除
</el-button>
</template>
</ty-crud>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import {
assignUserRoles,
createUser,
deleteUser,
listUsers,
resetPassword,
updateUser,
type UserListQuery,
type UserStatus,
} from '@/api/user'
import { listOrgs } from '@/api/org'
import ResetPasswordForm from './reset-password-form.vue'
import AssignRolesForm from './assign-roles-form.vue'
// 组织列表(下拉 + 名称回显)
const { data: orgs } = useRequest((signal: AbortSignal) => listOrgs(signal))
const columns = computed<TableColumn[]>(() => {
const orgList = orgs.value ?? []
const orgName = (code: unknown): string => orgList.find((o) => o.code === code)?.name ?? (code as string) ?? '-'
return [
{ prop: 'username', label: '用户名', minWidth: 110 },
{ prop: 'nickname', label: '昵称', minWidth: 100 },
{ prop: 'email', label: '邮箱', minWidth: 160 },
{ prop: 'phone', label: '手机', minWidth: 120 },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'user_status' },
{ prop: 'orgCode', label: '所属组织', minWidth: 110, formatter: (v) => orgName(v) },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
})
const searchSchema: JsonSchema = {
type: 'object',
properties: {
username: { type: 'string', title: '用户名', placeholder: '请输入用户名' },
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },
status: {
type: 'string',
title: '状态',
dictCode: 'user_status',
placeholder: '全部',
},
},
}
const formSchema = computed<JsonSchema>(() => ({
type: 'object',
properties: {
username: { type: 'string', title: '用户名', placeholder: '请输入用户名', minLength: 2 },
password: {
type: 'string',
title: '密码',
format: 'password',
placeholder: '请输入初始密码',
minLength: 6,
showOn: ['create'],
},
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
email: { type: 'string', title: '邮箱', format: 'email', placeholder: '请输入邮箱' },
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },
status: {
type: 'string',
title: '状态',
dictCode: 'user_status',
default: 'enabled',
},
orgCode: {
type: 'string',
title: '所属组织',
enum: (orgs.value ?? []).map((o) => o.code),
enumNames: (orgs.value ?? []).map((o) => o.name),
placeholder: '请选择组织',
},
},
required: ['username', 'password'],
}))
const actions: CrudAction[] = [
'edit',
'delete',
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
{ label: '重置密码', type: 'warning', permission: 'user:reset-pwd', onClick: (row) => resetPwd(row) },
]
function toListQuery(query: PageQuery): UserListQuery {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
return {
page: query.page,
pageSize: query.pageSize,
username: str(query.username),
nickname: str(query.nickname),
phone: str(query.phone),
status: str(query.status) as UserStatus | undefined,
}
}
const list = (query: PageQuery, signal?: AbortSignal) => listUsers(toListQuery(query), signal)
const create = (data: Record<string, any>) => createUser(data)
const update = (id: string, data: Record<string, any>) => updateUser(id, data)
const remove = (id: string) => deleteUser(id)
async function resetPwd(row: Record<string, any>): Promise<void> {
const result = await showDialog({
component: ResetPasswordForm,
props: { username: String(row.username) },
title: '重置密码',
width: '420px',
})
if (result == null) return
await resetPassword(String(row.id), result.password)
ElMessage.success('密码已重置')
}
async function assignRolesAction(row: Record<string, any>): Promise<void> {
const result = await showDialog({
component: AssignRolesForm,
props: { userId: String(row.id) },
title: `分配角色(${row.username})`,
width: '480px',
})
if (result == null) return
await assignUserRoles(String(row.id), result.roleCodes)
ElMessage.success('角色已保存')
}
const crudRef = ref()
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个用户?`, '提示', { type: 'warning' })
for (const r of rows) {
await deleteUser(String(r.id))
}
ElMessage.success('删除成功')
crudRef.value?.reload()
}
function escapeCsv(v: unknown): string {
const s = String(v ?? '')
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
async function exportUsers(): Promise<void> {
const result = await listUsers({ page: 1, pageSize: 1000 })
const headers = ['用户名', '昵称', '邮箱', '手机', '状态', '所属组织', '创建时间']
const lines = [headers.join(',')]
for (const u of result.list) {
lines.push([u.username, u.nickname, u.email, u.phone, u.status, u.orgCode, u.createdAt].map(escapeCsv).join(','))
}
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '用户列表.csv'
a.click()
URL.revokeObjectURL(url)
}
</script>

View File

@ -0,0 +1,27 @@
<template>
<div>
<div class="mb-md text-sm text-text-regular">用户:{{ username }}</div>
<el-input v-model="password" type="password" show-password placeholder="请输入新密码" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
defineOptions({ name: 'ResetPasswordForm' })
defineProps<{ username: string }>()
const password = ref('')
function submit(): { password: string } {
if (!password.value) {
ElMessage.warning('请输入新密码')
throw new Error('请输入新密码')
}
return { password: password.value }
}
defineExpose({ submit })
</script>

View File

@ -9,6 +9,7 @@ import { routes } from 'vue-router/auto-routes'
import { setupLayouts } from 'virtual:generated-layouts'
import i18n, { loadLanguage } from '@/i18n'
import { getAccessToken } from '@/utils/token'
import { useMenuStore } from '@/stores/modules/menu'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -39,15 +40,49 @@ async function loadRouteLocales(to: RouteLocationNormalized) {
}
}
// 无需菜单权限校验的路径(首页 + 错误页)
const FREE_PATHS = new Set(['/', '/403'])
/** 收集菜单树中所有可访问 path。 */
function collectMenuPaths(nodes: { path?: string | null; children?: unknown[] }[]): string[] {
const paths: string[] = []
for (const n of nodes) {
if (n.path) paths.push(n.path)
if (n.children?.length) paths.push(...collectMenuPaths(n.children as { path?: string | null; children?: unknown[] }[]))
}
return paths
}
// 路由前置守卫
router.beforeEach(async (to, from, next) => {
await loadRouteLocales(to)
// 未标记 requiresAuth: false 的路由默认需要登录
const requiresAuth = to.meta.requiresAuth !== false
if (requiresAuth && !getAccessToken()) {
next('/login')
} else {
if (!requiresAuth) {
next()
return
}
if (!getAccessToken()) {
next('/login')
return
}
// 路由级权限:自由路径放行,其余须在可访问菜单中
if (FREE_PATHS.has(to.path)) {
next()
return
}
const menuStore = useMenuStore()
try {
await menuStore.fetch()
} catch {
next('/login')
return
}
const accessible = new Set(collectMenuPaths(menuStore.menus))
if (accessible.has(to.path)) {
next()
} else {
next('/403')
}
})

49
src/stores/modules/app.ts Normal file
View File

@ -0,0 +1,49 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { defaultLayout, type LayoutType } from '@/config/layout'
/**
* 布局/主题偏好。默认值来自 src/config/layout.ts(集中配置),
* 用户在页面上的修改会覆盖默认值并持久化到 localStorage。
*/
export const useAppStore = defineStore(
'appStore',
() => {
const layout = ref<LayoutType>(defaultLayout.layout)
const mixbar = ref(defaultLayout.mixbar)
const themeColor = ref(defaultLayout.themeColor)
const isDark = ref(defaultLayout.isDark)
const headerFixed = ref(defaultLayout.headerFixed)
const sidebarWidth = ref(defaultLayout.sidebarWidth)
const footerFixed = ref(defaultLayout.footerFixed)
const sidebarCollapsed = ref(defaultLayout.sidebarCollapsed)
const showBreadcrumb = ref(defaultLayout.showBreadcrumb)
const showTabs = ref(defaultLayout.showTabs)
const showFullscreen = ref(defaultLayout.showFullscreen)
const menuAccordion = ref(defaultLayout.menuAccordion)
/** 是否渲染侧边栏:horizontal / sidebar 有,top 无。 */
const hasSidebar = computed(() => layout.value !== 'top')
/** 是否渲染 MixBar:仅左右(sidebar)布局可用。 */
const hasMixbar = computed(() => mixbar.value && layout.value === 'sidebar')
return {
layout,
mixbar,
themeColor,
isDark,
headerFixed,
sidebarWidth,
footerFixed,
sidebarCollapsed,
showBreadcrumb,
showTabs,
showFullscreen,
menuAccordion,
hasSidebar,
hasMixbar,
}
},
{ persist: true },
)

View File

@ -1,6 +1,7 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { http } from '@/api/client';
import { http } from '@/api/http';
import { login as loginApi, type LoginResult } from '@/api/auth';
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token';
export const useAuthStore = defineStore('authStore', () => {
@ -9,15 +10,14 @@ export const useAuthStore = defineStore('authStore', () => {
const isAuthenticated = computed(() => !!accessToken.value);
async function login(tenantCode: string, username: string, password: string): Promise<void> {
const data = await http.post<{ accessToken: string; refreshToken: string }>('/auth/login', {
tenantCode,
username,
password,
});
setTokens(data.accessToken, data.refreshToken);
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
async function login(username: string, password: string, tenantCode?: string): Promise<LoginResult> {
const data = await loginApi(username, password, tenantCode);
if ('accessToken' in data) {
setTokens(data.accessToken, data.refreshToken);
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
}
return data;
}
async function logout(): Promise<void> {
@ -34,5 +34,12 @@ export const useAuthStore = defineStore('authStore', () => {
refreshToken.value = null;
}
return { accessToken, refreshToken, isAuthenticated, login, logout };
/** 本地清空会话状态(不调用登出接口,供 401 强退使用)。 */
function reset(): void {
clearTokens();
accessToken.value = null;
refreshToken.value = null;
}
return { accessToken, refreshToken, isAuthenticated, login, logout, reset };
});

View File

@ -0,0 +1,31 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { listDicts, type DictOption } from '@/api/dict'
/** 字典缓存(登录后一次性拉取全部启用字典,非持久化)。 */
export const useDictStore = defineStore('dictStore', () => {
const dicts = ref<Record<string, DictOption[]>>({})
const loaded = ref(false)
const loading = ref(false)
async function fetch(force = false): Promise<void> {
if (!force && (loaded.value || loading.value)) return
loading.value = true
try {
const res = await listDicts()
const map: Record<string, DictOption[]> = {}
for (const d of res.list) map[d.code] = d.items
dicts.value = map
loaded.value = true
} finally {
loading.value = false
}
}
function reset(): void {
dicts.value = {}
loaded.value = false
}
return { dicts, loaded, loading, fetch, reset }
})

View File

@ -0,0 +1,29 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { fetchMenus } from '@/api/auth'
import type { ResourceNode } from '@/types/resource'
/** 当前用户菜单树(登录后从 /auth/menus 拉取,非持久化)。 */
export const useMenuStore = defineStore('menuStore', () => {
const menus = ref<ResourceNode[]>([])
const loaded = ref(false)
const loading = ref(false)
async function fetch(): Promise<void> {
if (loaded.value || loading.value) return
loading.value = true
try {
menus.value = await fetchMenus()
loaded.value = true
} finally {
loading.value = false
}
}
function reset(): void {
menus.value = []
loaded.value = false
}
return { menus, loaded, loading, fetch, reset }
})

View File

@ -0,0 +1,40 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { fetchPermissions } from '@/api/auth'
/** 当前用户权限码与超管标志(登录后拉取,供 v-permission 指令使用,非持久化)。 */
export const usePermissionStore = defineStore('permissionStore', () => {
const permissions = ref<string[]>([])
const isSuper = ref(false)
const loaded = ref(false)
const loading = ref(false)
async function fetch(): Promise<void> {
if (loaded.value || loading.value) return
loading.value = true
try {
const data = await fetchPermissions()
permissions.value = data.permissions
isSuper.value = data.super
loaded.value = true
} finally {
loading.value = false
}
}
function has(code: string): boolean {
return isSuper.value || permissions.value.includes(code)
}
function hasAny(codes: string[]): boolean {
return isSuper.value || codes.some((code) => permissions.value.includes(code))
}
function reset(): void {
permissions.value = []
isSuper.value = false
loaded.value = false
}
return { permissions, isSuper, loaded, loading, fetch, has, hasAny, reset }
})

View File

@ -0,0 +1,57 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export interface TabItem {
path: string
title: string
closable: boolean
}
export const HOME_PATH = '/'
/** 多页签状态(持久化,刷新保留)。 */
export const useTabsStore = defineStore(
'tabsStore',
() => {
const tabs = ref<TabItem[]>([{ path: HOME_PATH, title: '首页', closable: false }])
function addTab(tab: TabItem): void {
if (!tabs.value.some((t) => t.path === tab.path)) {
tabs.value.push(tab)
}
}
function removeTab(path: string): void {
const tab = tabs.value.find((t) => t.path === path)
if (!tab || !tab.closable) return
tabs.value = tabs.value.filter((t) => t.path !== path)
}
/** 关闭其他(保留首页与指定 tab)。 */
function removeOthers(path: string): void {
tabs.value = tabs.value.filter((t) => !t.closable || t.path === path)
}
/** 关闭左侧(保留首页、指定 tab 及其右侧)。 */
function removeLeft(path: string): void {
const index = tabs.value.findIndex((t) => t.path === path)
if (index < 0) return
tabs.value = tabs.value.filter((t, i) => !t.closable || i >= index)
}
/** 关闭右侧(保留首页、指定 tab 及其左侧)。 */
function removeRight(path: string): void {
const index = tabs.value.findIndex((t) => t.path === path)
if (index < 0) return
tabs.value = tabs.value.filter((t, i) => !t.closable || i <= index)
}
/** 关闭全部(仅保留首页)。 */
function removeAll(): void {
tabs.value = tabs.value.filter((t) => !t.closable)
}
return { tabs, addTab, removeTab, removeOthers, removeLeft, removeRight, removeAll }
},
{ persist: true },
)

94
src/types/crud.ts Normal file
View File

@ -0,0 +1,94 @@
/** JSON Schema 表单 + 表格列的共享类型。 */
export type JsonType = 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array'
/** JSON Schema 属性(表单字段描述)。 */
export interface JsonSchemaProperty {
type?: JsonType
/** 显示标题。 */
title?: string
description?: string
/** 格式化:password/email/textarea 等。 */
format?: 'password' | 'email' | 'textarea' | 'date' | 'datetime' | 'tree-select'
/** 枚举值(渲染为下拉)。 */
enum?: (string | number | boolean)[]
/** 枚举显示名(与 enum 一一对应)。 */
enumNames?: string[]
/** 字典 code:声明后从字典解析下拉选项(优先于 enum/enumNames)。 */
dictCode?: string
default?: unknown
/** 只读(禁用编辑)。 */
readOnly?: boolean
/** 仅在指定模式下显示(如 password 仅 create)。缺省表示所有模式都显示。 */
showOn?: ('create' | 'edit')[]
minLength?: number
maxLength?: number
minimum?: number
maximum?: number
placeholder?: string
/** 输入框前缀图标(iconify 图标名,如 mdi:account)。 */
prefixIcon?: string
/** 控件宽度(如 '160px');缺省时搜索行内默认 200px、弹窗表单默认 100%。 */
width?: string
/** format=tree-select 时的树数据([{ label, value, children }])。 */
treeData?: Record<string, any>[]
/** format=tree-select 时的树字段映射。 */
treeProps?: { label?: string; value?: string; children?: string }
/** 字段显示条件:仅当依赖字段值匹配时显示(如 type=menu 时显示 path)。 */
when?: { field: string; equals?: unknown; in?: unknown[] }
}
/** JSON Schema 对象(表单 schema)。 */
export interface JsonSchema {
type: 'object'
properties: Record<string, JsonSchemaProperty>
required?: string[]
}
export type TagType = 'success' | 'info' | 'warning' | 'danger' | 'primary'
/** 表格列配置。 */
export interface TableColumn {
prop: string
label: string
width?: number | string
minWidth?: number | string
align?: 'left' | 'center' | 'right'
/** 渲染类型:text(默认)/tag(状态标签)/datetime(毫秒时间戳)。 */
type?: 'text' | 'tag' | 'datetime'
/** type=tag 时的值→{label,type} 映射。 */
tagMap?: Record<string, { label: string; type: TagType }>
/** 字典 code:type=tag 时按字典渲染标签(优先于 tagMap)。 */
dictCode?: string
/** 自定义格式化。 */
formatter?: (value: unknown, row: Record<string, unknown>) => string
}
/** 分页结果。 */
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}
/** 分页查询参数。 */
export interface PageQuery {
page: number
pageSize: number
[key: string]: unknown
}
/** 操作列自定义动作。 */
export interface CrudActionItem {
label: string
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
onClick: (row: Record<string, any>) => void | Promise<void>
/** 是否对当前行显示(缺省显示)。 */
show?: (row: Record<string, any>) => boolean
/** 所需权限码(无该权限且非超管时隐藏)。 */
permission?: string
}
/** 操作列动作:内置 'edit'/'delete' 或自定义动作。 */
export type CrudAction = 'edit' | 'delete' | CrudActionItem

20
src/types/resource.ts Normal file
View File

@ -0,0 +1,20 @@
/** 资源类型(对齐后端 sys_resource.type)。 */
export type ResourceType = 'menu' | 'button' | 'api'
/**
* 资源/菜单节点,字段严格对齐后端 Resource 模型
* (id/code/name/parentCode/type/path/component/icon/sort)。
* `children` 为前端按 parentCode 组装的渲染字段。
*/
export interface ResourceNode {
id: string
code: string
name: string
parentCode: string | null
type: ResourceType
path: string | null
component: string | null
icon: string | null
sort: number
children?: ResourceNode[]
}

50
src/utils/dialog.ts Normal file
View File

@ -0,0 +1,50 @@
import { markRaw, reactive, type Component } from 'vue'
export interface DialogOptions {
/** 对话框内容组件。 */
component: Component
title?: string
width?: string
/** 传给内容组件的 props。 */
props?: Record<string, any>
confirmText?: string
cancelText?: string
/** 是否显示默认 取消/确定 底部(false 时内容组件自带底部)。 */
showFooter?: boolean
/** 确认回调,参数为内容组件 submit() 返回的数据。 */
onConfirm?: (data: any) => void | Promise<void>
}
export interface DialogInstance {
id: number
options: DialogOptions
resolve: (value: any) => void
}
/** 当前打开的对话框栈(由 <ty-dialog /> 宿主渲染)。 */
export const dialogs = reactive<DialogInstance[]>([])
let seq = 0
/**
* 打开一个对话框。返回 Promise:确认时 resolve 内容组件 submit() 返回的数据,
* 取消/关闭时 resolve null。
*/
export function showDialog(options: DialogOptions): Promise<any> {
return new Promise((resolve) => {
dialogs.push({
id: ++seq,
options: { ...options, component: markRaw(options.component) },
resolve,
})
})
}
/** 关闭指定对话框并 resolve。 */
export function closeDialog(id: number, result?: any): void {
const idx = dialogs.findIndex((d) => d.id === id)
if (idx >= 0) {
const [dialog] = dialogs.splice(idx, 1)
dialog.resolve(result)
}
}

87
src/utils/theme.ts Normal file
View File

@ -0,0 +1,87 @@
const THEME_STYLE_ID = 'sysmgr-theme'
/** 十六进制颜色 → RGB 三元组。 */
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace('#', '')
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
const num = parseInt(h, 16)
return [(num >> 16) & 255, (num >> 8) & 255, num & 255]
}
/** RGB → 十六进制颜色。 */
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`
}
/** 颜色混合:weight 为 target 占比(0=纯 base,1=纯 target)。 */
function mix(base: string, target: string, weight: number): string {
const [r1, g1, b1] = hexToRgb(base)
const [r2, g2, b2] = hexToRgb(target)
return rgbToHex(r1 + (r2 - r1) * weight, g1 + (g2 - g1) * weight, b1 + (b2 - b1) * weight)
}
/** 生成一组 CSS 变量声明。 */
function cssVars(vars: [string, string][]): string {
return vars.map(([k, v]) => `${k}: ${v};`).join('')
}
/**
* 由主色生成明/暗两套 Element Plus 主色派生变量与 --wm-color-* 令牌,
* 注入 <style id="sysmgr-theme">。暗黑模式把 light-N 改为混黑(作深色高亮底)、
* dark-2 改为混白(作深色对比色),保证暗黑下侧栏/按钮高亮不刺眼。
*/
export function applyThemeColor(primary: string): void {
const mixWhite = (w: number) => mix(primary, '#ffffff', w)
const mixBlack = (w: number) => mix(primary, '#000000', w)
// 明亮模式
const light: [string, string][] = [
['--el-color-primary', primary],
['--el-color-primary-light-3', mixWhite(0.3)],
['--el-color-primary-light-5', mixWhite(0.5)],
['--el-color-primary-light-7', mixWhite(0.7)],
['--el-color-primary-light-8', mixWhite(0.8)],
['--el-color-primary-light-9', mixWhite(0.9)],
['--el-color-primary-dark-2', mixBlack(0.2)],
['--wm-color-primary', primary],
...([1, 2, 3, 5, 7, 8, 9].map((i) => [`--wm-color-primary-light-${i}`, mixWhite(i / 10)] as [string, string])),
['--wm-color-primary-dark-1', mixBlack(0.1)],
['--wm-color-primary-dark-2', mixBlack(0.2)],
]
// 暗黑模式:light-N 混黑、dark-2 混白
const dark: [string, string][] = [
['--el-color-primary', primary],
['--el-color-primary-light-3', mixBlack(0.2)],
['--el-color-primary-light-5', mixBlack(0.4)],
['--el-color-primary-light-7', mixBlack(0.6)],
['--el-color-primary-light-8', mixBlack(0.7)],
['--el-color-primary-light-9', mixBlack(0.8)],
['--el-color-primary-dark-2', mixWhite(0.3)],
['--wm-color-primary', primary],
...([1, 2, 3, 5, 7, 8, 9].map((i) => [`--wm-color-primary-light-${i}`, mixBlack(i / 10)] as [string, string])),
['--wm-color-primary-dark-1', mixWhite(0.1)],
['--wm-color-primary-dark-2', mixWhite(0.2)],
]
let el = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null
if (!el) {
el = document.createElement('style')
el.id = THEME_STYLE_ID
document.head.appendChild(el)
}
el.textContent = `:root{${cssVars(light)}}html.dark{${cssVars(dark)}}`
}
/** 应用暗黑模式:同时切换 Element Plus 的 html.dark 与现有 data-theme 令牌。 */
export function applyDarkMode(isDark: boolean): void {
const root = document.documentElement
root.classList.toggle('dark', isDark)
root.setAttribute('data-theme', isDark ? 'dark' : 'light')
}
/** 统一应用主题(颜色 + 暗黑)。 */
export function applyTheme(themeColor: string, isDark: boolean): void {
applyThemeColor(themeColor)
applyDarkMode(isDark)
}

72
src/utils/tree.ts Normal file
View File

@ -0,0 +1,72 @@
/** 树操作工具:用于 code/parentCode 组织的扁平树数据。 */
export interface FlatNode {
code: string
parentCode: string | null
sort?: number
}
/** 树节点:扁平节点 + children。 */
export type TreeNode<T extends FlatNode> = T & { children: TreeNode<T>[] }
/** 由扁平列表构建树(按 sort + code 升序)。 */
export function buildTree<T extends FlatNode>(rows: T[]): TreeNode<T>[] {
const map = new Map<string, TreeNode<T>>()
for (const r of rows) map.set(r.code, { ...r, children: [] })
const roots: TreeNode<T>[] = []
for (const node of map.values()) {
const parent = node.parentCode ? map.get(node.parentCode) : undefined
if (parent) parent.children.push(node)
else roots.push(node)
}
const sortRec = (nodes: TreeNode<T>[]): void => {
nodes.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.code.localeCompare(b.code))
for (const n of nodes) sortRec(n.children)
}
sortRec(roots)
return roots
}
/** 过滤:保留匹配节点 + 其祖先 + 其整棵子树。 */
export function filterTree<T extends FlatNode>(rows: T[], match: (row: T) => boolean): T[] {
const matched = rows.filter(match)
if (matched.length === rows.length) return rows
const byCode = new Map(rows.map((r) => [r.code, r]))
const kept = new Set<string>()
for (const m of matched) {
// 祖先链
let cur: T | undefined = m
while (cur) {
kept.add(cur.code)
cur = cur.parentCode ? byCode.get(cur.parentCode) : undefined
}
// 子树
const stack = [m.code]
while (stack.length) {
const c = stack.pop()!
for (const r of rows) {
if (r.parentCode === c) {
kept.add(r.code)
stack.push(r.code)
}
}
}
}
return rows.filter((r) => kept.has(r.code))
}
/** 收集某节点及其全部后代 code。 */
export function collectDescendants<T extends FlatNode>(rows: T[], code: string): string[] {
const result: string[] = []
const stack = [code]
while (stack.length) {
const c = stack.pop()!
for (const r of rows) {
if (r.parentCode === c) {
result.push(r.code)
stack.push(r.code)
}
}
}
return result
}

4950
stats.html

File diff suppressed because one or more lines are too long

221
typed-router.d.ts vendored
View File

@ -30,6 +30,20 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/[...all]': RouteRecordInfo<
'/[...all]',
'/:all(.*)',
{ all: ParamValue<true> },
{ all: ParamValue<false> },
| never
>,
'/403': RouteRecordInfo<
'/403',
'/403',
Record<never, never>,
Record<never, never>,
| never
>,
'/demo': RouteRecordInfo<
'/demo',
'/demo',
@ -37,6 +51,111 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/forgot-password': RouteRecordInfo<
'/forgot-password',
'/forgot-password',
Record<never, never>,
Record<never, never>,
| never
>,
'/login/': RouteRecordInfo<
'/login/',
'/login',
Record<never, never>,
Record<never, never>,
| never
>,
'/monitor/audit/': RouteRecordInfo<
'/monitor/audit/',
'/monitor/audit',
Record<never, never>,
Record<never, never>,
| never
>,
'/monitor/login-log/': RouteRecordInfo<
'/monitor/login-log/',
'/monitor/login-log',
Record<never, never>,
Record<never, never>,
| never
>,
'/register': RouteRecordInfo<
'/register',
'/register',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/config/': RouteRecordInfo<
'/system/config/',
'/system/config',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/dict/': RouteRecordInfo<
'/system/dict/',
'/system/dict',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/org/': RouteRecordInfo<
'/system/org/',
'/system/org',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/resource/': RouteRecordInfo<
'/system/resource/',
'/system/resource',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/role/': RouteRecordInfo<
'/system/role/',
'/system/role',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/role/assign-resources-form': RouteRecordInfo<
'/system/role/assign-resources-form',
'/system/role/assign-resources-form',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/tenant/': RouteRecordInfo<
'/system/tenant/',
'/system/tenant',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/user/': RouteRecordInfo<
'/system/user/',
'/system/user',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/user/assign-roles-form': RouteRecordInfo<
'/system/user/assign-roles-form',
'/system/user/assign-roles-form',
Record<never, never>,
Record<never, never>,
| never
>,
'/system/user/reset-password-form': RouteRecordInfo<
'/system/user/reset-password-form',
'/system/user/reset-password-form',
Record<never, never>,
Record<never, never>,
| never
>,
}
/**
@ -56,12 +175,114 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/[...all].vue': {
routes:
| '/[...all]'
views:
| never
}
'src/pages/403.vue': {
routes:
| '/403'
views:
| never
}
'src/pages/demo.vue': {
routes:
| '/demo'
views:
| never
}
'src/pages/forgot-password.vue': {
routes:
| '/forgot-password'
views:
| never
}
'src/pages/login/index.vue': {
routes:
| '/login/'
views:
| never
}
'src/pages/monitor/audit/index.vue': {
routes:
| '/monitor/audit/'
views:
| never
}
'src/pages/monitor/login-log/index.vue': {
routes:
| '/monitor/login-log/'
views:
| never
}
'src/pages/register.vue': {
routes:
| '/register'
views:
| never
}
'src/pages/system/config/index.vue': {
routes:
| '/system/config/'
views:
| never
}
'src/pages/system/dict/index.vue': {
routes:
| '/system/dict/'
views:
| never
}
'src/pages/system/org/index.vue': {
routes:
| '/system/org/'
views:
| never
}
'src/pages/system/resource/index.vue': {
routes:
| '/system/resource/'
views:
| never
}
'src/pages/system/role/index.vue': {
routes:
| '/system/role/'
views:
| never
}
'src/pages/system/role/assign-resources-form.vue': {
routes:
| '/system/role/assign-resources-form'
views:
| never
}
'src/pages/system/tenant/index.vue': {
routes:
| '/system/tenant/'
views:
| never
}
'src/pages/system/user/index.vue': {
routes:
| '/system/user/'
views:
| never
}
'src/pages/system/user/assign-roles-form.vue': {
routes:
| '/system/user/assign-roles-form'
views:
| never
}
'src/pages/system/user/reset-password-form.vue': {
routes:
| '/system/user/reset-password-form'
views:
| never
}
}
/**

View File

@ -68,15 +68,13 @@ export default defineConfig({
},
},
rules: [
...['primary', 'success', 'warning', 'danger', 'info'].flatMap((color) => [
rules: ['primary', 'success', 'warning', 'danger', 'info'].flatMap((color) => [
[`bg-${color}-light-3`, { 'background-color': `var(--wm-color-${color}-light-3)` }],
[`bg-${color}-light-5`, { 'background-color': `var(--wm-color-${color}-light-5)` }],
[`bg-${color}-light-7`, { 'background-color': `var(--wm-color-${color}-light-7)` }],
[`bg-${color}-light-9`, { 'background-color': `var(--wm-color-${color}-light-9)` }],
[`text-${color}-dark-2`, { color: `var(--wm-color-${color}-dark-2)` }],
]),
],
shortcuts: {
// 布局

View File

@ -10,6 +10,7 @@ import Layouts from 'vite-plugin-vue-layouts'
import AutoImport from 'unplugin-auto-import/vite'
import { VueRouterAutoImports } from 'unplugin-vue-router'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import vueI18n from '@intlify/unplugin-vue-i18n/vite'
import path from 'node:path'
import { compression } from 'vite-plugin-compression2'
@ -51,7 +52,7 @@ export default defineConfig(({ mode }) => {
},
}
: {},
olldownOptions: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
@ -99,10 +100,12 @@ export default defineConfig(({ mode }) => {
/\.md$/, // .md
],
imports: ['vue', VueRouterAutoImports, 'pinia', '@vueuse/core'],
resolvers: [ElementPlusResolver()],
}),
Components({
deep: true,
directoryAsNamespace: false,
resolvers: [ElementPlusResolver()],
}),
vueI18n({
// 语言包目录