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:
parent
c95aa594cb
commit
77917573c1
@ -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
109
pnpm-lock.yaml
generated
@ -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
7
src/api/auth.ts
Normal 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')
|
||||
}
|
||||
@ -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
127
src/api/http.ts
Normal 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()
|
||||
123
src/api/user.ts
123
src/api/user.ts
@ -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 })
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())}`
|
||||
}
|
||||
|
||||
110
src/composables/useRequest.ts
Normal file
110
src/composables/useRequest.ts
Normal 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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
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 }
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user