From 77917573c14d89f53c412b97c147d48c4a0544ab Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 12:19:24 +0800 Subject: [PATCH] feat: real menu rendering + axios/useRequest integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 1 + pnpm-lock.yaml | 109 ++++++++++++++ src/api/auth.ts | 7 + src/api/client.ts | 77 ---------- src/api/http.ts | 127 +++++++++++++++++ src/api/user.ts | 123 +++++----------- src/components/ty-crud.vue | 33 +++-- src/components/ty-table.vue | 3 +- src/composables/useRequest.ts | 110 ++++++++++++++ src/config/login.ts | 6 +- src/layouts/default.vue | 22 ++- src/pages/login/index.vue | 14 +- src/pages/system/user/index.vue | 244 +++++++------------------------- src/stores/modules/auth.ts | 2 +- src/stores/modules/menu.ts | 29 ++++ stats.html | 2 +- 16 files changed, 518 insertions(+), 391 deletions(-) create mode 100644 src/api/auth.ts delete mode 100644 src/api/client.ts create mode 100644 src/api/http.ts create mode 100644 src/composables/useRequest.ts create mode 100644 src/stores/modules/menu.ts diff --git a/package.json b/package.json index f24972b..1e6076a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68be1d1..0f4b56a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..4192fff --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,7 @@ +import { http } from '@/api/http' +import type { ResourceNode } from '@/types/resource' + +/** 当前用户菜单树。 */ +export function fetchMenus(): Promise { + return http.get('/auth/menus') +} diff --git a/src/api/client.ts b/src/api/client.ts deleted file mode 100644 index 9e736ba..0000000 --- a/src/api/client.ts +++ /dev/null @@ -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 { - code: number; - message: string; - data: T; -} - -export class ApiError extends Error { - constructor( - public code: number, - message: string, - ) { - super(message); - } -} - -async function rawRequest(path: string, options: RequestInit = {}, token?: string): Promise> { - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record | undefined), - }; - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch(`${BASE_URL}${path}`, { ...options, headers }); - return (await res.json()) as ApiEnvelope; -} - -let refreshing: Promise | null = null; - -async function tryRefresh(): Promise { - 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(path: string, options: RequestInit = {}): Promise { - const attempt = async (retry: boolean): Promise => { - const body = await rawRequest(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: (path: string) => request(path, { method: 'GET' }), - post: (path: string, data?: unknown) => request(path, { method: 'POST', body: JSON.stringify(data ?? {}) }), - patch: (path: string, data?: unknown) => request(path, { method: 'PATCH', body: JSON.stringify(data ?? {}) }), - delete: (path: string) => request(path, { method: 'DELETE' }), -}; diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..6d5f3de --- /dev/null +++ b/src/api/http.ts @@ -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 { + 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 | 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 { + if (!this.refreshing) { + this.refreshing = this.doRefresh().finally(() => { + this.refreshing = null + }) + } + return this.refreshing + } + + private async doRefresh(): Promise { + const refreshToken = getRefreshToken() + if (!refreshToken) throw new ApiError(40100, '未登录') + const { data } = await this.rawInstance.post>( + '/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(config: AxiosRequestConfig): Promise { + return this.instance.request(config) as Promise + } + + get(url: string, config?: AxiosRequestConfig): Promise { + return this.request({ ...config, method: 'GET', url }) + } + + post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + return this.request({ ...config, method: 'POST', url, data }) + } + + put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + return this.request({ ...config, method: 'PUT', url, data }) + } + + patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + return this.request({ ...config, method: 'PATCH', url, data }) + } + + delete(url: string, config?: AxiosRequestConfig): Promise { + return this.request({ ...config, method: 'DELETE', url }) + } +} + +/** 全局单例,业务直接使用 http.get/post/... */ +export const http = new HttpClient() diff --git a/src/api/user.ts b/src/api/user.ts index a4afc5f..2a1ab68 100644 --- a/src/api/user.ts +++ b/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 { + const params: Record = { 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('/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 { + return http.post('/users', data) } -export async function listUsers(query: UserListQuery): Promise { - 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 { + return http.patch(`/users/${id}`, data) } -export async function createUser(data: UserUpsertData): Promise { - 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 { + return http.delete(`/users/${id}`) } -export async function updateUser(id: string, data: Partial): Promise { - 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 { + return http.patch(`/users/${id}/password`, { password }) } -export async function deleteUser(id: string): Promise { - mockUsers = mockUsers.filter((u) => u.id !== id) - return null -} - -export async function resetPassword(_id: string, _password: string): Promise { - // mock:无真实密码存储,仅占位 - return null +/** 组织列表(用于组织下拉)。 */ +export function listOrgs(signal?: AbortSignal): Promise { + return http.get('/orgs', { signal }) } diff --git a/src/components/ty-crud.vue b/src/components/ty-crud.vue index 976ec37..f028802 100644 --- a/src/components/ty-crud.vue +++ b/src/components/ty-crud.vue @@ -67,7 +67,7 @@