feat: real menu rendering + axios/useRequest integration

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 12:19:24 +08:00
parent c95aa594cb
commit 77917573c1
16 changed files with 518 additions and 391 deletions

View File

@ -22,6 +22,7 @@
"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",

109
pnpm-lock.yaml generated
View File

@ -172,6 +172,9 @@ importers:
'@vueuse/core':
specifier: ^14.3.0
version: 14.3.0(vue@3.5.38(typescript@6.0.3))
axios:
specifier: ^1.20.0
version: 1.20.0(debug@4.4.3(supports-color@3.2.3))(supports-color@3.2.3)
element-plus:
specifier: ^2.14.7
version: 2.14.7(vue@3.5.38(typescript@6.0.3))
@ -2182,6 +2185,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@ -2255,6 +2262,9 @@ packages:
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atob@2.1.2:
resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==}
engines: {node: '>= 4.5.0'}
@ -2264,6 +2274,9 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
babel-plugin-polyfill-corejs2@0.4.17:
resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==}
peerDependencies:
@ -2390,6 +2403,10 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@ -2536,6 +2553,10 @@ packages:
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
@ -2806,6 +2827,15 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
follow-redirects@1.16.1:
resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@ -2814,6 +2844,10 @@ packages:
resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==}
engines: {node: '>=0.10.0'}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
fragment-cache@0.2.1:
resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==}
engines: {node: '>=0.10.0'}
@ -2939,6 +2973,10 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
@ -2949,6 +2987,10 @@ packages:
htmlparser2@3.10.1:
resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -3405,6 +3447,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@ -3688,6 +3738,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -6407,6 +6461,12 @@ snapshots:
acorn@8.16.0: {}
agent-base@6.0.2(supports-color@3.2.3):
dependencies:
debug: 4.4.3(supports-color@3.2.3)
transitivePeerDependencies:
- supports-color
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@ -6467,12 +6527,24 @@ snapshots:
async-validator@4.2.5: {}
asynckit@0.4.0: {}
atob@2.1.2: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
axios@1.20.0(debug@4.4.3(supports-color@3.2.3))(supports-color@3.2.3):
dependencies:
follow-redirects: 1.16.1(debug@4.4.3(supports-color@3.2.3))
form-data: 4.0.6
https-proxy-agent: 5.0.1(supports-color@3.2.3)
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0(supports-color@3.2.3))(supports-color@3.2.3):
dependencies:
'@babel/compat-data': 7.29.3
@ -6636,6 +6708,10 @@ snapshots:
colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3: {}
commander@7.2.0: {}
@ -6773,6 +6849,8 @@ snapshots:
defu@6.1.7: {}
delayed-stream@1.0.0: {}
destr@2.0.5: {}
detect-libc@2.1.2: {}
@ -7164,12 +7242,24 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.16.1(debug@4.4.3(supports-color@3.2.3)):
optionalDependencies:
debug: 4.4.3(supports-color@3.2.3)
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
for-in@1.0.2: {}
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
fragment-cache@0.2.1:
dependencies:
map-cache: 0.2.2
@ -7296,6 +7386,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
hookable@5.5.3: {}
@ -7309,6 +7403,13 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
https-proxy-agent@5.0.1(supports-color@3.2.3):
dependencies:
agent-base: 6.0.2(supports-color@3.2.3)
debug: 4.4.3(supports-color@3.2.3)
transitivePeerDependencies:
- supports-color
ignore@5.3.2: {}
ignore@7.0.5: {}
@ -7717,6 +7818,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
@ -8035,6 +8142,8 @@ snapshots:
prettier@3.8.4: {}
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
quansync@0.2.11: {}

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

@ -0,0 +1,7 @@
import { http } from '@/api/http'
import type { ResourceNode } from '@/types/resource'
/** 当前用户菜单树。 */
export function fetchMenus(): Promise<ResourceNode[]> {
return http.get<ResourceNode[]>('/auth/menus')
}

View File

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

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

@ -0,0 +1,127 @@
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'
}
}
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
// 401 自动刷新并重放一次
if (error.response?.status === 401 && config) {
try {
const token = await this.refresh()
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${token}`
return this.instance.request(config)
} catch {
clearTokens()
}
}
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()

View File

@ -1,8 +1,4 @@
/**
* 用户管理数据源。
* TODO: 对接后端后,把下面 mock 实现替换为 http 调用(字段与返回形状保持不变)。
* 后端 User 实体字段:id/tenantCode/username/nickname/email/phone/status/orgCode/createdAt
*/
import { http } from '@/api/http'
export type UserStatus = 'enabled' | 'disabled' | 'locked'
@ -13,9 +9,11 @@ export interface User {
nickname: string | null
email: string | null
phone: string | null
avatar: string | null
status: UserStatus
orgCode: string | null
createdAt: number
createdAt: string
updatedAt: string
}
export interface UserListQuery {
@ -35,105 +33,52 @@ export interface UserListResult {
}
export interface UserUpsertData {
username: string
username?: string
password?: string
nickname?: string
email?: string
phone?: string
status?: UserStatus
orgCode?: string | null
password?: string
status?: UserStatus
}
/** 占位组织(后续接后端换真实组织数据)。 */
export const mockOrgs: { code: string; name: string }[] = [
{ code: 'touyun', name: '投云总部' },
{ code: 'rd', name: '研发部' },
{ code: 'ops', name: '运维部' },
{ code: 'sales', name: '销售部' },
]
const now = Date.now()
const day = 24 * 60 * 60 * 1000
function mockUser(id: string, username: string, nickname: string, orgCode: string, status: UserStatus, daysAgo: number): User {
return {
id,
tenantCode: 'touyun',
username,
nickname,
email: `${username}@example.com`,
phone: `1380000${String(1000 + Number(id)).slice(-4)}`,
status,
orgCode,
createdAt: now - daysAgo * day,
}
export interface Org {
id: string
code: string
name: string
}
let mockUsers: User[] = [
mockUser('1', 'touyun', '超级管理员', 'touyun', 'enabled', 30),
mockUser('2', 'zhangsan', '张三', 'rd', 'enabled', 20),
mockUser('3', 'lisi', '李四', 'rd', 'disabled', 18),
mockUser('4', 'wangwu', '王五', 'ops', 'locked', 15),
mockUser('5', 'zhaoliu', '赵六', 'sales', 'enabled', 12),
mockUser('6', 'sunqi', '孙七', 'ops', 'enabled', 9),
mockUser('7', 'zhouba', '周八', 'rd', 'enabled', 7),
mockUser('8', 'wujiu', '吴九', 'sales', 'disabled', 5),
mockUser('9', 'zhengshi', '郑十', 'rd', 'enabled', 3),
mockUser('10', 'chenyi', '陈一', 'sales', 'enabled', 1),
]
function uuid(): string {
return crypto.randomUUID()
/** 用户分页列表。 */
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 orgName(code: string | null): string {
if (!code) return '-'
return mockOrgs.find((o) => o.code === code)?.name ?? code
/** 新增用户。 */
export function createUser(data: UserUpsertData): Promise<User> {
return http.post<User>('/users', data)
}
export async function listUsers(query: UserListQuery): Promise<UserListResult> {
const { page, pageSize, username, nickname, phone, status } = query
const filtered = mockUsers.filter((u) => {
if (username && !u.username.includes(username)) return false
if (nickname && !(u.nickname ?? '').includes(nickname)) return false
if (phone && !(u.phone ?? '').includes(phone)) return false
if (status && u.status !== status) return false
return true
})
const total = filtered.length
const start = (page - 1) * pageSize
return { list: filtered.slice(start, start + pageSize), total, page, pageSize }
/** 更新用户(username 不可改)。 */
export function updateUser(id: string, data: UserUpsertData): Promise<User> {
return http.patch<User>(`/users/${id}`, data)
}
export async function createUser(data: UserUpsertData): Promise<User> {
const user: User = {
id: uuid(),
tenantCode: 'touyun',
username: data.username,
nickname: data.nickname ?? null,
email: data.email ?? null,
phone: data.phone ?? null,
status: data.status ?? 'enabled',
orgCode: data.orgCode ?? null,
createdAt: Date.now(),
}
mockUsers.unshift(user)
return user
/** 删除用户(软删)。 */
export function deleteUser(id: string): Promise<unknown> {
return http.delete(`/users/${id}`)
}
export async function updateUser(id: string, data: Partial<UserUpsertData>): Promise<User> {
const user = mockUsers.find((u) => u.id === id)
if (!user) throw new Error('用户不存在')
Object.assign(user, data)
return user
/** 重置密码。 */
export function resetPassword(id: string, password: string): Promise<unknown> {
return http.patch(`/users/${id}/password`, { password })
}
export async function deleteUser(id: string): Promise<null> {
mockUsers = mockUsers.filter((u) => u.id !== id)
return null
}
export async function resetPassword(_id: string, _password: string): Promise<null> {
// mock:无真实密码存储,仅占位
return null
/** 组织列表(用于组织下拉)。 */
export function listOrgs(signal?: AbortSignal): Promise<Org[]> {
return http.get<Org[]>('/orgs', { signal })
}

View File

@ -67,7 +67,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type {
CrudAction,
@ -79,6 +79,7 @@ import type {
} from '@/types/crud'
import TyForm from '@/components/ty-form.vue'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
defineOptions({ name: 'TyCrud' })
@ -92,8 +93,8 @@ const props = withDefaults(
formSchema: JsonSchema
/** 搜索 JSON Schema(可选)。 */
searchSchema?: JsonSchema
/** 数据加载:分页查询。 */
list: (query: PageQuery) => Promise<PageResult<Record<string, any>>>
/** 数据加载:分页查询(signal 用于取消请求)。 */
list: (query: PageQuery, signal?: AbortSignal) => Promise<PageResult<Record<string, any>>>
/** 新增。 */
create: (data: Record<string, any>) => Promise<unknown>
/** 更新。 */
@ -126,7 +127,6 @@ const props = withDefaults(
},
)
const loading = ref(false)
const list = ref<Record<string, any>[]>([])
const total = ref(0)
const page = ref(1)
@ -147,16 +147,21 @@ function buildModel(schema?: JsonSchema): Record<string, any> {
return model
}
async function fetchList(): Promise<void> {
loading.value = true
try {
const result = await props.list({ page: page.value, pageSize: pageSize.value, ...query })
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
} finally {
loading.value = false
}
}
},
},
)
// 分页变化时重新请求
watch([page, pageSize], () => {
fetchList()
})
function handleSearch(): void {
page.value = 1

View File

@ -127,7 +127,8 @@ function onSelectionChange(rows: Record<string, any>[]): void {
function formatDateTime(value: unknown): string {
if (value == null || value === '') return '-'
const d = new Date(Number(value))
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())}`
}

View File

@ -0,0 +1,110 @@
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
async function run(...args: TParams): Promise<TData | undefined> {
controller?.abort()
controller = new AbortController()
lastParams = args.length > 0 ? args : lastParams
loading.value = true
error.value = undefined
options.onBefore?.(lastParams)
try {
const result = await fetcher(...lastParams, controller.signal)
data.value = result
options.onSuccess?.(result, lastParams)
return result
} catch (e) {
if (isCanceled(e)) return undefined
error.value = e
options.onError?.(e, lastParams)
return undefined
} finally {
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)
}

View File

@ -18,6 +18,8 @@ export interface LoginConfig {
layout: LoginLayout
/** 背景(图 URL 或 CSS 渐变/颜色)。 */
background?: string
/** 登录提交的租户编码(页面不展示租户输入框,统一用此默认租户)。 */
tenantCode: string
/** 登录字段(JSON Schema,字段名须与后端 DTO 一致)。 */
fields: JsonSchema
/** 是否显示「记住我」。 */
@ -38,10 +40,10 @@ export const defaultLoginConfig: LoginConfig = {
subtitle: '通用系统管理脚手架',
copyright: '云南投云科技有限公司',
layout: 'split',
tenantCode: 'touyun',
fields: {
type: 'object',
properties: {
tenantCode: { type: 'string', title: '租户编码', placeholder: '请输入租户编码', prefixIcon: 'mdi:domain' },
username: { type: 'string', title: '用户名', placeholder: '请输入用户名', prefixIcon: 'mdi:account' },
password: {
type: 'string',
@ -51,7 +53,7 @@ export const defaultLoginConfig: LoginConfig = {
prefixIcon: 'mdi:lock',
},
},
required: ['tenantCode', 'username', 'password'],
required: ['username', 'password'],
},
showRemember: true,
showForgetPassword: true,

View File

@ -67,12 +67,12 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/modules/app'
import { HOME_PATH, useTabsStore } from '@/stores/modules/tabs'
import { useAuthStore } from '@/stores/modules/auth'
import { placeholderMenus } from '@/config/menu'
import { useMenuStore } from '@/stores/modules/menu'
import logo from '@/assets/images/logo.png'
import type { LayoutSettings } from '@/config/layout'
import type { ResourceNode } from '@/types/resource'
@ -82,20 +82,26 @@ import { applyTheme } from '@/utils/theme'
const app = useAppStore()
const tabsStore = useTabsStore()
const auth = useAuthStore()
const menuStore = useMenuStore()
const route = useRoute()
const router = useRouter()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
const title = '投云科技'
const HEADER_HEIGHT = 56
const menus: ResourceNode[] = placeholderMenus
const menus = computed(() => menuStore.menus)
const settingsOpen = ref(false)
// 登录后拉取菜单
onMounted(() => {
menuStore.fetch()
})
const activePath = computed(() => route.path)
/** 当前路由对应的顶级菜单(及其叶子)。 */
const activeTop = computed<ResourceNode | null>(() => {
for (const m of menus) {
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
}
@ -106,7 +112,7 @@ const activeTop = computed<ResourceNode | null>(() => {
const sidebarMenus = computed<ResourceNode[]>(() => {
if (app.layout === 'top') return []
if (app.layout === 'horizontal') return activeTop.value?.children ?? []
return app.mixbar ? (activeTop.value?.children ?? []) : menus
return app.mixbar ? (activeTop.value?.children ?? []) : menus.value
})
/** 是否显示左侧菜单:有侧栏且当前顶级下存在子菜单。 */
@ -172,7 +178,7 @@ function handleNavigate(index: string): void {
}
function handleTopSelect(code: string): void {
const top = menus.find((m) => m.code === code)
const top = menus.value.find((m) => m.code === code)
const target = top ? firstLeafPath(top) : null
if (target) router.push(target)
}
@ -212,6 +218,8 @@ function handleCloseAll(): void {
async function handleLogout(): Promise<void> {
await auth.logout()
menuStore.reset()
tabsStore.removeAll()
router.push('/login')
}
@ -224,7 +232,7 @@ function resolveTarget(index: string): string | null {
}
function findMenu(index: string): ResourceNode | null {
for (const m of menus) {
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

View File

@ -6,7 +6,7 @@ meta:
<template>
<div class="login" :class="`login--${config.layout}`" :style="rootStyle">
<div v-if="config.layout === 'split'" class="login__brand">
<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>
@ -139,6 +139,15 @@ const error = ref('')
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)) {
@ -179,7 +188,7 @@ async function onSubmit(): Promise<void> {
}
loading.value = true
try {
await auth.login(model.tenantCode, model.username, model.password)
await auth.login(config.tenantCode, model.username, model.password)
router.push(config.homePath)
} catch (e) {
error.value = (e as Error).message
@ -211,7 +220,6 @@ function onRegister(): void {
.login__brand {
@apply relative hidden md:flex flex-col overflow-hidden;
width: 44%;
background: linear-gradient(160deg, var(--wm-color-primary), var(--wm-color-primary-dark-2));
color: #fff;
}

View File

@ -28,10 +28,21 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { CrudAction, JsonSchema, PageQuery, PageResult, TableColumn } from '@/types/crud'
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
import { showDialog } from '@/utils/dialog'
import { useRequest } from '@/composables/useRequest'
import {
createUser,
deleteUser,
listOrgs,
listUsers,
resetPassword,
updateUser,
type UserListQuery,
type UserStatus,
} from '@/api/user'
import ResetPasswordForm from './reset-password-form.vue'
const STATUS_MAP = {
@ -40,17 +51,13 @@ const STATUS_MAP = {
locked: { label: '锁定', type: 'danger' },
} as const
const orgs = [
{ code: 'touyun', name: '投云总部' },
{ code: 'rd', name: '研发部' },
{ code: 'ops', name: '运维部' },
{ code: 'sales', name: '销售部' },
]
// 组织列表(下拉 + 名称回显)
const { data: orgs } = useRequest((signal: AbortSignal) => listOrgs(signal))
const orgName = (code: unknown): string =>
orgs.find((o) => o.code === code)?.name ?? (code as string) ?? '-'
const columns: TableColumn[] = [
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 },
@ -58,7 +65,8 @@ const columns: TableColumn[] = [
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', tagMap: STATUS_MAP },
{ prop: 'orgCode', label: '所属组织', minWidth: 110, formatter: (v) => orgName(v) },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
]
})
const searchSchema: JsonSchema = {
type: 'object',
@ -76,7 +84,7 @@ const searchSchema: JsonSchema = {
},
}
const formSchema: JsonSchema = {
const formSchema = computed<JsonSchema>(() => ({
type: 'object',
properties: {
username: { type: 'string', title: '用户名', placeholder: '请输入用户名', minLength: 2 },
@ -101,13 +109,13 @@ const formSchema: JsonSchema = {
orgCode: {
type: 'string',
title: '所属组织',
enum: orgs.map((o) => o.code),
enumNames: orgs.map((o) => o.name),
enum: (orgs.value ?? []).map((o) => o.code),
enumNames: (orgs.value ?? []).map((o) => o.name),
placeholder: '请选择组织',
},
},
required: ['username', 'password'],
}
}))
const actions: CrudAction[] = [
'edit',
@ -115,177 +123,22 @@ const actions: CrudAction[] = [
{ label: '重置密码', type: 'warning', onClick: (row) => resetPwd(row) },
]
// ---- 占位数据(后续对接后端时替换为 http 调用)----
interface UserRow {
id: string
username: string
nickname: string
email: string
phone: string
status: 'enabled' | 'disabled' | 'locked'
orgCode: string
createdAt: number
}
const now = Date.now()
const day = 24 * 60 * 60 * 1000
const users: UserRow[] = [
{
id: '1',
username: 'touyun',
nickname: '超级管理员',
email: 'touyun@example.com',
phone: '13800000001',
status: 'enabled',
orgCode: 'touyun',
createdAt: now - 30 * day,
},
{
id: '2',
username: 'zhangsan',
nickname: '张三',
email: 'zhangsan@example.com',
phone: '13800000002',
status: 'enabled',
orgCode: 'rd',
createdAt: now - 20 * day,
},
{
id: '3',
username: 'lisi',
nickname: '李四',
email: 'lisi@example.com',
phone: '13800000003',
status: 'disabled',
orgCode: 'rd',
createdAt: now - 18 * day,
},
{
id: '4',
username: 'wangwu',
nickname: '王五',
email: 'wangwu@example.com',
phone: '13800000004',
status: 'locked',
orgCode: 'ops',
createdAt: now - 15 * day,
},
{
id: '5',
username: 'zhaoliu',
nickname: '赵六',
email: 'zhaoliu@example.com',
phone: '13800000005',
status: 'enabled',
orgCode: 'sales',
createdAt: now - 12 * day,
},
{
id: '6',
username: 'sunqi',
nickname: '孙七',
email: 'sunqi@example.com',
phone: '13800000006',
status: 'enabled',
orgCode: 'ops',
createdAt: now - 9 * day,
},
{
id: '7',
username: 'zhouba',
nickname: '周八',
email: 'zhouba@example.com',
phone: '13800000007',
status: 'enabled',
orgCode: 'rd',
createdAt: now - 7 * day,
},
{
id: '8',
username: 'wujiu',
nickname: '吴九',
email: 'wujiu@example.com',
phone: '13800000008',
status: 'disabled',
orgCode: 'sales',
createdAt: now - 5 * day,
},
{
id: '9',
username: 'zhengshi',
nickname: '郑十',
email: 'zhengshi@example.com',
phone: '13800000009',
status: 'enabled',
orgCode: 'rd',
createdAt: now - 3 * day,
},
{
id: '10',
username: 'chenyi',
nickname: '陈一',
email: 'chenyi@example.com',
phone: '13800000010',
status: 'enabled',
orgCode: 'sales',
createdAt: now - 1 * day,
},
]
let seq = 0
const nextId = () => `u-${Date.now()}-${++seq}`
function delay<T>(fn: () => T, ms = 300): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(fn()), ms))
}
const list = (query: PageQuery): Promise<PageResult<Record<string, any>>> =>
delay(() => {
const { page, pageSize, username, nickname, phone, status } = query
const filtered = users.filter((u) => {
if (username && !u.username.includes(String(username))) return false
if (nickname && !u.nickname.includes(String(nickname))) return false
if (phone && !u.phone.includes(String(phone))) return false
if (status && u.status !== status) return false
return true
})
const start = (page - 1) * pageSize
function toListQuery(query: PageQuery): UserListQuery {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
return {
list: filtered.slice(start, start + pageSize) as unknown as Record<string, any>[],
total: filtered.length,
page,
pageSize,
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 create = (data: Record<string, any>): Promise<unknown> =>
delay(() => {
users.unshift({
id: nextId(),
username: data.username,
nickname: data.nickname,
email: data.email,
phone: data.phone,
status: data.status,
orgCode: data.orgCode,
createdAt: Date.now(),
})
return null
})
const update = (id: string, data: Record<string, any>): Promise<unknown> =>
delay(() => {
const u = users.find((x) => x.id === id)
if (u) Object.assign(u, data)
return null
})
const remove = (id: string): Promise<unknown> =>
delay(() => {
const idx = users.findIndex((x) => x.id === id)
if (idx >= 0) users.splice(idx, 1)
return null
})
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({
@ -295,7 +148,7 @@ async function resetPwd(row: Record<string, any>): Promise<void> {
width: '420px',
})
if (result == null) return
// mock:无真实密码存储,仅模拟
await resetPassword(String(row.id), result.password)
ElMessage.success('密码已重置')
}
@ -304,8 +157,7 @@ const crudRef = ref()
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个用户?`, '提示', { type: 'warning' })
for (const r of rows) {
const i = users.findIndex((x) => x.id === r.id)
if (i >= 0) users.splice(i, 1)
await deleteUser(String(r.id))
}
ElMessage.success('删除成功')
crudRef.value?.reload()

View File

@ -1,6 +1,6 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { http } from '@/api/client';
import { http } from '@/api/http';
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token';
export const useAuthStore = defineStore('authStore', () => {

View File

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

File diff suppressed because one or more lines are too long