Compare commits
41 Commits
7738abbbfd
...
e1bc973034
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bc973034 | ||
|
|
cf54ea2352 | ||
|
|
0a1fbf3df2 | ||
|
|
77c9c3fd25 | ||
|
|
ed1a1e115c | ||
|
|
42b0a6a479 | ||
|
|
b03e9ef62b | ||
|
|
23da579b26 | ||
|
|
84edbc67c0 | ||
|
|
2f7f83b8b1 | ||
|
|
7ae220581a | ||
|
|
23e228783c | ||
|
|
c96151d2ae | ||
|
|
8742932f21 | ||
|
|
7d0088dd10 | ||
|
|
f1cb5723b2 | ||
|
|
77917573c1 | ||
|
|
c95aa594cb | ||
|
|
b9c3a64c34 | ||
|
|
b5d95c95d8 | ||
|
|
35892486e8 | ||
|
|
16b512a3f1 | ||
|
|
1df44fd57e | ||
|
|
d88b807638 | ||
|
|
d9f1a4d17e | ||
|
|
1699fee4a8 | ||
|
|
bc26c0aa93 | ||
|
|
1ee3ed28a5 | ||
|
|
104b7678d3 | ||
|
|
45b22acd6c | ||
|
|
5549b2e75c | ||
|
|
246a305df5 | ||
|
|
537dba9847 | ||
|
|
c5e044ea42 | ||
|
|
1f06594252 | ||
|
|
82aa944d48 | ||
|
|
99650949c0 | ||
|
|
66f4a38c98 | ||
|
|
8a0f510e3a | ||
|
|
86af3c10e1 | ||
|
|
6ef0b1121c |
5
.env
5
.env
@ -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
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
# 后端 API 基地址
|
||||
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -37,3 +37,9 @@ __screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
stats.html
|
||||
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal 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
1
auto-imports.d.ts
vendored
@ -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
99
components.d.ts
vendored
@ -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']
|
||||
}
|
||||
10
package.json
10
package.json
@ -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
1487
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
11
src/App.vue
11
src/App.vue
@ -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
34
src/api/audit-log.ts
Normal 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
46
src/api/auth.ts
Normal 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 })
|
||||
}
|
||||
@ -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
54
src/api/config.ts
Normal 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
98
src/api/dict.ts
Normal 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
138
src/api/http.ts
Normal 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
27
src/api/login-log.ts
Normal 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
39
src/api/org.ts
Normal 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
51
src/api/resource.ts
Normal 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
62
src/api/role.ts
Normal 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
45
src/api/tenant.ts
Normal 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
83
src/api/user.ts
Normal 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
BIN
src/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@ -1,3 +1,4 @@
|
||||
// 导入变量文件
|
||||
@use "element-plus/dist/index.css";
|
||||
@use "./settings";
|
||||
@use "./base";
|
||||
|
||||
48
src/components/change-password-form.vue
Normal file
48
src/components/change-password-form.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
27
src/components/ty-breadcrumb.vue
Normal file
27
src/components/ty-breadcrumb.vue
Normal 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
309
src/components/ty-crud.vue
Normal 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>
|
||||
61
src/components/ty-dialog.vue
Normal file
61
src/components/ty-dialog.vue
Normal 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>
|
||||
34
src/components/ty-footer.vue
Normal file
34
src/components/ty-footer.vue
Normal 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
206
src/components/ty-form.vue
Normal 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>
|
||||
241
src/components/ty-header.vue
Normal file
241
src/components/ty-header.vue
Normal 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>
|
||||
58
src/components/ty-mixbar.vue
Normal file
58
src/components/ty-mixbar.vue
Normal 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>
|
||||
63
src/components/ty-search-form.vue
Normal file
63
src/components/ty-search-form.vue
Normal 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>
|
||||
120
src/components/ty-setting-panel.vue
Normal file
120
src/components/ty-setting-panel.vue
Normal 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>
|
||||
22
src/components/ty-setting.vue
Normal file
22
src/components/ty-setting.vue
Normal 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>
|
||||
123
src/components/ty-sidebar.vue
Normal file
123
src/components/ty-sidebar.vue
Normal 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
163
src/components/ty-table.vue
Normal 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
129
src/components/ty-tabs.vue
Normal 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>
|
||||
27
src/composables/useDict.ts
Normal file
27
src/composables/useDict.ts
Normal 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 }
|
||||
}
|
||||
114
src/composables/useRequest.ts
Normal file
114
src/composables/useRequest.ts
Normal 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
36
src/config/layout.ts
Normal 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
63
src/config/login.ts
Normal 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: '/',
|
||||
}
|
||||
25
src/directives/permission.ts
Normal file
25
src/directives/permission.ts
Normal 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
12
src/layouts/blank.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
38
src/main.ts
38
src/main.ts
@ -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
25
src/pages/403.vue
Normal 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
40
src/pages/[...all].vue
Normal 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>
|
||||
161
src/pages/forgot-password.vue
Normal file
161
src/pages/forgot-password.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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
428
src/pages/login/index.vue
Normal 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>
|
||||
109
src/pages/monitor/audit/index.vue
Normal file
109
src/pages/monitor/audit/index.vue
Normal 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>
|
||||
103
src/pages/monitor/login-log/index.vue
Normal file
103
src/pages/monitor/login-log/index.vue
Normal 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
186
src/pages/register.vue
Normal 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>
|
||||
60
src/pages/system/config/index.vue
Normal file
60
src/pages/system/config/index.vue
Normal 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>
|
||||
345
src/pages/system/dict/index.vue
Normal file
345
src/pages/system/dict/index.vue
Normal 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>
|
||||
278
src/pages/system/org/index.vue
Normal file
278
src/pages/system/org/index.vue
Normal 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>
|
||||
328
src/pages/system/resource/index.vue
Normal file
328
src/pages/system/resource/index.vue
Normal 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>
|
||||
59
src/pages/system/role/assign-resources-form.vue
Normal file
59
src/pages/system/role/assign-resources-form.vue
Normal 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>
|
||||
106
src/pages/system/role/index.vue
Normal file
106
src/pages/system/role/index.vue
Normal 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>
|
||||
76
src/pages/system/tenant/index.vue
Normal file
76
src/pages/system/tenant/index.vue
Normal 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>
|
||||
55
src/pages/system/user/assign-roles-form.vue
Normal file
55
src/pages/system/user/assign-roles-form.vue
Normal 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>
|
||||
197
src/pages/system/user/index.vue
Normal file
197
src/pages/system/user/index.vue
Normal 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>
|
||||
27
src/pages/system/user/reset-password-form.vue
Normal file
27
src/pages/system/user/reset-password-form.vue
Normal 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>
|
||||
@ -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
49
src/stores/modules/app.ts
Normal 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 },
|
||||
)
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
31
src/stores/modules/dict.ts
Normal file
31
src/stores/modules/dict.ts
Normal 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 }
|
||||
})
|
||||
29
src/stores/modules/menu.ts
Normal file
29
src/stores/modules/menu.ts
Normal 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 }
|
||||
})
|
||||
40
src/stores/modules/permission.ts
Normal file
40
src/stores/modules/permission.ts
Normal 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 }
|
||||
})
|
||||
57
src/stores/modules/tabs.ts
Normal file
57
src/stores/modules/tabs.ts
Normal 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
94
src/types/crud.ts
Normal 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
20
src/types/resource.ts
Normal 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
50
src/utils/dialog.ts
Normal 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
87
src/utils/theme.ts
Normal 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
72
src/utils/tree.ts
Normal 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
4950
stats.html
File diff suppressed because one or more lines are too long
221
typed-router.d.ts
vendored
221
typed-router.d.ts
vendored
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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: {
|
||||
// 布局
|
||||
|
||||
@ -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({
|
||||
// 语言包目录
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user