diff --git a/.env.development b/.env.development index 60906d47..c026485a 100644 --- a/.env.development +++ b/.env.development @@ -1,18 +1,22 @@ # 应用端口 VITE_APP_PORT=3000 + # 项目名称 VITE_APP_TITLE=vue3-element-admin + # 代理前缀 -VITE_APP_BASE_API=/dev-api +VITE_API_BASE=/dev-api -# 接口地址 -VITE_APP_API_URL=https://api.youlai.tech # 线上 -# VITE_APP_API_URL=https://api.youlai.tech/v2 # 线上(多租户) -# VITE_APP_API_URL=http://localhost:8000 # 本地 +# 接口地址(开发代理转发目标,默认走线上) +VITE_PROXY_TARGET=https://api.youlai.tech # 线上 +# VITE_PROXY_TARGET=http://localhost:8000 # 本地 +# VITE_PROXY_TARGET=https://api.youlai.tech/v2 # 多租户线上 +# Mock 开关 +VITE_MOCK_ENABLED=false -# 启用 Mock 服务(true:开启 false:关闭) -VITE_MOCK_DEV_SERVER=false +# 多租户开关 +VITE_TENANT_ENABLED=false -# 多租户开关(true:开启 false:关闭) -VITE_APP_TENANT_ENABLED=false +# AI 开关 +VITE_AI_ENABLED=true diff --git a/.env.production b/.env.production index 6858016b..2b18b8cf 100644 --- a/.env.production +++ b/.env.production @@ -1,8 +1,11 @@ # 代理前缀 -VITE_APP_BASE_API = '/prod-api' +VITE_API_BASE=/prod-api + # 项目名称 VITE_APP_TITLE=vue3-element-admin -# 多租户开关(true/false) -VITE_APP_TENANT_ENABLED=false +# 多租户开关(true:开启 false:关闭) +VITE_TENANT_ENABLED=false +# AI 辅助功能开关(true:开启 false:关闭,缺省关闭) +# VITE_AI_ENABLED=true diff --git a/README.en-US.md b/README.en-US.md index 18c1ff75..bb2f9106 100644 --- a/README.en-US.md +++ b/README.en-US.md @@ -174,9 +174,9 @@ For the detailed deployment flow (Nginx config, reverse proxy, HTTPS, etc.), see The frontend uses the online API by default, and can also switch to local Mock or connect to a local backend. -**Local Mock**: Set `VITE_MOCK_DEV_SERVER` in `.env.development` to `true` to enable local Mock interfaces, allowing standalone development without a backend. +**Local Mock**: Set `VITE_MOCK_ENABLED` in `.env.development` to `true` to enable local Mock interfaces, allowing standalone development without a backend. -**Connect to Backend**: All nine backends use the default port `8000`. Change `VITE_APP_API_URL` in `.env.development` to `http://localhost:8000` and start the corresponding backend (the recommended Java backend is [youlai-boot](https://gitee.com/youlaiorg/youlai-boot.git); see each repo's README for the rest). +**Connect to Backend**: All nine backends use the default port `8000`. Change `VITE_PROXY_TARGET` in `.env.development` to `http://localhost:8000` and start the corresponding backend (the recommended Java backend is [youlai-boot](https://gitee.com/youlaiorg/youlai-boot.git); see each repo's README for the rest). ## Commit Conventions diff --git a/README.md b/README.md index 5d028eab..7d5d8013 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,9 @@ pnpm run build 前端默认使用线上接口,也可切换为本地 Mock 或对接本地后端。 -**本地 Mock**:将 `.env.development` 的 `VITE_MOCK_DEV_SERVER` 设为 `true` 即启用本地 Mock 接口,无需后端即可独立开发。 +**本地 Mock**:将 `.env.development` 的 `VITE_MOCK_ENABLED` 设为 `true` 即启用本地 Mock 接口,无需后端即可独立开发。 -**对接后端**:九种后端默认端口均为 `8000`,将 `.env.development` 的 `VITE_APP_API_URL` 改为 `http://localhost:8000` 并启动对应后端即可(主推 Java 后端 [youlai-boot](https://gitee.com/youlaiorg/youlai-boot.git),其余见各自仓库 README)。 +**对接后端**:九种后端默认端口均为 `8000`,将 `.env.development` 的 `VITE_PROXY_TARGET` 改为 `http://localhost:8000` 并启动对应后端即可(主推 Java 后端 [youlai-boot](https://gitee.com/youlaiorg/youlai-boot.git),其余见各自仓库 README)。 ## 提交规范 diff --git a/mock/base.ts b/mock/base.ts index e68e642f..dcccbdaa 100644 --- a/mock/base.ts +++ b/mock/base.ts @@ -3,5 +3,5 @@ import { createDefineMock } from "vite-plugin-mock-dev-server"; export const defineMock = createDefineMock((mock) => { // 拼接url - mock.url = path.join(import.meta.env.VITE_APP_BASE_API + "/api/v1/", mock.url); + mock.url = path.join(import.meta.env.VITE_API_BASE + "/api/v1/", mock.url); }); diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts index eb4b0574..b96312ad 100644 --- a/src/api/auth/index.ts +++ b/src/api/auth/index.ts @@ -1,54 +1,61 @@ import request from "@/utils/request"; -import type { CaptchaInfo, LoginRequest, LoginResult } from "./types"; - -/** 申请扫码票据的响应:票据字符串与有效期(秒) */ -export interface QrCodeGenerateResult { - /** 一次扫码登录会话的唯一票据,由 PC 端编码进二维码 */ - ticket: string; - /** 票据有效期(秒),默认 300,过期后 Redis 自动清理 */ - expireSeconds: number; -} - -/** 轮询扫码状态的响应 */ -export interface QrCodeStatusResult { - /** 当前票据 */ - ticket: string; - /** 状态:WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED */ - status: "WAITING" | "SCANNED" | "CONFIRMED" | "LOGGED_IN" | "CANCELED" | "EXPIRED"; - /** 脱敏昵称,仅 SCANNED 之后返回,WAITING 为 undefined */ - nickname?: string; - /** 头像 URL,仅 SCANNED 之后返回 */ - avatar?: string; - /** 票据剩余有效期(秒) */ - expireSeconds: number; -} +import type { + CaptchaInfo, + LoginRequest, + LoginResult, + QrCodeGenerateResult, + QrCodeStatusResult, +} from "./types"; const AUTH_BASE_URL = "/api/v1/auth"; const AuthAPI = { + /** + * 登录 + */ login(data: LoginRequest) { - const payload: Pick< - LoginRequest, - "username" | "password" | "captchaId" | "captchaCode" | "tenantId" - > = { + const payload = { username: data.username, password: data.password, captchaId: data.captchaId, captchaCode: data.captchaCode, + ...(typeof data.tenantId !== "undefined" && { tenantId: data.tenantId }), }; - // 仅多租户场景传入 tenantId - if (typeof data.tenantId !== "undefined") { - payload.tenantId = data.tenantId; - } - return request({ url: `${AUTH_BASE_URL}/login`, method: "post", data: payload, + // 白名单接口携带过期令牌仍会被 Spring Security 判 401 + anonymous: true, }); }, + /** + * 获取验证码图片 + */ + getCaptcha() { + return request({ + url: `${AUTH_BASE_URL}/captcha`, + method: "get", + // 残留过期令牌会让白名单接口 401,登录页取不到验证码 + anonymous: true, + }); + }, + + /** + * 退出登录 + */ + logout() { + return request({ + url: `${AUTH_BASE_URL}/logout`, + method: "delete", + }); + }, + + /** + * 切换租户并重新签发令牌 + */ switchTenant(tenantId: number) { return request({ url: `${AUTH_BASE_URL}/switch-tenant`, @@ -57,58 +64,54 @@ const AuthAPI = { }); }, + /** + * 用刷新令牌换取新的访问令牌 + */ refreshToken(refreshToken: string) { return request({ url: `${AUTH_BASE_URL}/refresh-token`, method: "post", params: { refreshToken }, - headers: { - Authorization: "no-auth", - }, + anonymous: true, }); }, - logout() { - return request({ - url: `${AUTH_BASE_URL}/logout`, - method: "delete", - }); - }, - - getCaptcha() { - return request({ - url: `${AUTH_BASE_URL}/captcha`, - method: "get", - }); - }, - - // ============ 扫码登录 ============ - + /** + * 生成扫码登录票据 + */ qrGenerate(): Promise { return request({ url: `${AUTH_BASE_URL}/qr-code/generate`, method: "post", + anonymous: true, }); }, + /** + * 轮询扫码登录状态 + */ qrStatus(ticket: string): Promise { return request({ url: `${AUTH_BASE_URL}/qr-code/status`, method: "get", params: { ticket }, + anonymous: true, }); }, + /** + * 用票据换取登录令牌 + */ qrLogin(ticket: string): Promise { return request({ url: `${AUTH_BASE_URL}/qr-code/login`, method: "post", data: { ticket }, + anonymous: true, }); }, }; export default AuthAPI; -// 重导出类型 export * from "./types"; diff --git a/src/api/auth/types.ts b/src/api/auth/types.ts index 51f3b42d..ae496a65 100644 --- a/src/api/auth/types.ts +++ b/src/api/auth/types.ts @@ -39,3 +39,26 @@ export interface CaptchaInfo { /** 验证码图片 Base64 */ captchaBase64: string; } + +/** + * 申请扫码票据的响应 + */ +export interface QrCodeGenerateResult { + ticket: string; + /** 票据有效期(秒) */ + expireSeconds: number; +} + +/** + * 轮询扫码状态的响应 + */ +export interface QrCodeStatusResult { + ticket: string; + /** 状态:WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED */ + status: "WAITING" | "SCANNED" | "CONFIRMED" | "LOGGED_IN" | "CANCELED" | "EXPIRED"; + /** 脱敏昵称,SCANNED 后才有值 */ + nickname?: string; + avatar?: string; + /** 票据剩余有效期(秒) */ + expireSeconds: number; +} diff --git a/src/api/codegen/index.ts b/src/api/codegen/index.ts index 183d9f2b..b5f2eb0c 100644 --- a/src/api/codegen/index.ts +++ b/src/api/codegen/index.ts @@ -17,7 +17,9 @@ const buildCodegenParams = (pageType?: "classic" | "curd", type?: "ts" | "js") = }; const GeneratorAPI = { - /** 获取数据表分页列表 */ + /** + * 获取数据表分页列表 + */ getTablePage(params: TableQueryParams) { return request>({ url: `${GENERATOR_BASE_URL}/table`, @@ -26,7 +28,9 @@ const GeneratorAPI = { }); }, - /** 获取代码生成配置 */ + /** + * 获取代码生成配置 + */ getGenConfig(tableName: string) { return request({ url: `${GENERATOR_BASE_URL}/${tableName}/config`, @@ -34,7 +38,20 @@ const GeneratorAPI = { }); }, - /** 保存代码生成配置 */ + /** + * AI 推断代码生成配置,未开启 AI 时返回业务异常 + */ + aiFillConfig(tableName: string, requirement?: string) { + return request({ + url: `${GENERATOR_BASE_URL}/${tableName}/ai-config`, + method: "post", + data: requirement ? { requirement } : undefined, + }); + }, + + /** + * 保存代码生成配置 + */ saveGenConfig(tableName: string, data: GenConfigForm) { return request({ url: `${GENERATOR_BASE_URL}/${tableName}/config`, @@ -43,7 +60,9 @@ const GeneratorAPI = { }); }, - /** 获取代码生成预览数据 */ + /** + * 获取代码生成预览数据 + */ getPreviewData(tableName: string, pageType?: "classic" | "curd", type?: "ts" | "js") { return request({ url: `${GENERATOR_BASE_URL}/${tableName}/preview`, @@ -52,7 +71,9 @@ const GeneratorAPI = { }); }, - /** 重置代码生成配置 */ + /** + * 重置代码生成配置 + */ resetGenConfig(tableName: string) { return request({ url: `${GENERATOR_BASE_URL}/${tableName}/config`, @@ -60,7 +81,9 @@ const GeneratorAPI = { }); }, - /** 下载代码生成 ZIP 文件 */ + /** + * 下载代码生成 ZIP 文件 + */ download(tableName: string, pageType?: "classic" | "curd", type?: "ts" | "js") { return request({ url: `${GENERATOR_BASE_URL}/${tableName}/download`, @@ -96,5 +119,4 @@ const GeneratorAPI = { export default GeneratorAPI; -// 重导出类型 export * from "./types"; diff --git a/src/api/form/index.ts b/src/api/form/index.ts index 792d0dfc..7ec9f60f 100644 --- a/src/api/form/index.ts +++ b/src/api/form/index.ts @@ -21,6 +21,14 @@ const FormAPI = { method: "get", }); }, + /** AI 生成表单规则(未开启 AI 时接口不存在) */ + aiGenerate(description: string) { + return request({ + url: `${FORM_BASE_URL}/ai-generate`, + method: "post", + data: { description }, + }); + }, /** 表单定义分页列表 */ getPage(queryParams: FormDefinitionQueryParams) { return request>({ @@ -68,8 +76,8 @@ const FormAPI = { return request({ url: `${FORM_BASE_URL}/public/${formKey}/render`, method: "get", - // 匿名页显式跳过 token 注入,避免已登录管理员预览分享链接时携带身份 - headers: { Authorization: "no-auth" }, + // 已登录管理员打开分享链接时不能混入自身令牌 + anonymous: true, }); }, /** 匿名提交公开表单数据(后端按 formKey + IP 限流防刷) */ @@ -78,7 +86,7 @@ const FormAPI = { url: `${FORM_BASE_URL}/public/${formKey}/data`, method: "post", data, - headers: { Authorization: "no-auth" }, + anonymous: true, }); }, /** 表单数据分页列表 */ diff --git a/src/api/workflow/index.ts b/src/api/workflow/index.ts index dce5a709..7c1f4d12 100644 --- a/src/api/workflow/index.ts +++ b/src/api/workflow/index.ts @@ -173,6 +173,13 @@ const TaskAPI = { method: "get", }); }, + /** AI 生成任务审批摘要(未开启 AI 时接口不存在) */ + aiSummary(taskId: string) { + return request({ + url: `${WORKFLOW_BASE_URL}/tasks/${taskId}/ai-summary`, + method: "post", + }); + }, /** 获取驳回目标节点列表 */ listRejectTargets(taskId: string) { return request({ diff --git a/src/assets/icons/ai.svg b/src/assets/icons/ai.svg new file mode 100644 index 00000000..75ae79b1 --- /dev/null +++ b/src/assets/icons/ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/login/bg-dark.svg b/src/assets/images/login/bg-dark.svg deleted file mode 100644 index 033700cb..00000000 --- a/src/assets/images/login/bg-dark.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/images/login/bg.svg b/src/assets/images/login/bg.svg deleted file mode 100644 index 530cc736..00000000 --- a/src/assets/images/login/bg.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/components/NoticeCenter/index.vue b/src/components/NoticeCenter/index.vue index ae87b6d9..0482a4ec 100644 --- a/src/components/NoticeCenter/index.vue +++ b/src/components/NoticeCenter/index.vue @@ -67,29 +67,7 @@ - -
-
- - - {{ detail.publisherName }} - - - - {{ detail.publishTime }} - -
- -
-
-
-
-
+ + + diff --git a/src/router/guards/permission.ts b/src/router/guards/permission.ts index d1c019e6..8eda3974 100644 --- a/src/router/guards/permission.ts +++ b/src/router/guards/permission.ts @@ -46,7 +46,7 @@ export function setupPermissionGuard() { await userStore.getUserInfo(); } - // 加载用户租户列表(VITE_APP_TENANT_ENABLED=true 时生效) + // 加载用户租户列表(VITE_TENANT_ENABLED=true 时生效) await initTenantContext(); const dynamicRoutes = await permissionStore.generateRoutes(); diff --git a/src/settings.ts b/src/settings.ts index 8a5a971d..636bf559 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -69,13 +69,24 @@ export const themePalettePresets = [ export const defaultThemePalette = themePalettePresets[0]; +/** + * 解析布尔环境变量,仅 "true" 视为开启,未设置或为空时取默认值 + * + * @param value 环境变量原始值 + * @param defaultValue 未设置时的取值 + */ +function envBool(value: string | undefined, defaultValue: boolean): boolean { + return value == null || value === "" ? defaultValue : value === "true"; +} + export const appConfig = { name: pkg.name as string, version: pkg.version as string, title: (env.VITE_APP_TITLE as string) || pkg.name, - // 功能开关 - tenantEnabled: env.VITE_APP_TENANT_ENABLED === "true", + // 功能开关(缺省均关闭,需要时在 .env 中显式开启) + tenantEnabled: envBool(env.VITE_TENANT_ENABLED, false), + aiEnabled: envBool(env.VITE_AI_ENABLED, false), } as const; export const defaults = { diff --git a/src/stores/user.ts b/src/stores/user.ts index d2f1a504..6b49f7b9 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -12,8 +12,16 @@ import { useTagsViewStore } from "@/stores"; import { cleanupSse } from "@/utils/sse"; import router from "@/router"; -// 防止并发会话失效触发重复跳转登录页 -let redirectingToLogin = false; +// 会话失效已处理,登录成功后复位 +let sessionExpired = false; + +// 连续续期失败上限 +const MAX_REFRESH_FAILURES = 3; +// 连续续期失败次数 +let refreshFailures = 0; + +// 跳转登录页的原因(URL reason 参数的取值) +type RedirectReason = "expired" | "password-changed"; export const useUserStore = defineStore("user", () => { // 用户信息 @@ -21,29 +29,27 @@ export const useUserStore = defineStore("user", () => { // 记住我状态 const rememberMe = ref(AuthStorage.getRememberMe()); - /** - * 登录 - */ + // 登录 async function login(loginRequest: LoginRequest): Promise { const { accessToken, refreshToken } = await AuthAPI.login(loginRequest); rememberMe.value = loginRequest.rememberMe ?? false; AuthStorage.setTokens(accessToken, refreshToken, rememberMe.value); + refreshFailures = 0; + sessionExpired = false; } - /** - * 扫码登录:用票据换取会话令牌 - */ + // 扫码登录:用票据换取会话令牌 async function loginByQrCode(ticket: string): Promise { const { accessToken, refreshToken } = await AuthAPI.qrLogin(ticket); AuthStorage.setTokens(accessToken, refreshToken, false); + refreshFailures = 0; + sessionExpired = false; } let refreshPromise: Promise | null = null; /** - * 刷新 token(单飞模式) - * - * 多个并发请求遇到 token 过期时,共享同一次 refresh 请求。 + * 刷新 token(单飞):并发请求共享同一次 refresh */ function refreshTokenOnce(): Promise { if (refreshPromise) return refreshPromise; @@ -55,9 +61,12 @@ export const useUserStore = defineStore("user", () => { return refreshPromise; } - /** - * 获取用户信息 - */ + // 等待进行中的续期;无续期时立即返回 + function waitRefresh(): Promise { + return refreshPromise ?? Promise.resolve(); + } + + // 获取用户信息 async function getUserInfo(): Promise { const data = await UserAPI.getInfo(); if (!data) { @@ -67,19 +76,13 @@ export const useUserStore = defineStore("user", () => { return data; } - /** - * 登出 - */ + // 登出 async function logout(): Promise { await AuthAPI.logout(); resetAllState(); } - /** - * 重置所有系统状态 - * - * 统一处理所有清理工作,包括用户凭证、路由、缓存等 - */ + // 重置所有系统状态(凭证、路由、缓存、SSE) function resetAllState(): void { // 1. 重置用户状态 resetUserState(); @@ -94,66 +97,52 @@ export const useUserStore = defineStore("user", () => { cleanupSse(); } - /** - * 重置用户状态 - * - * 仅处理用户模块内的状态 - */ + // 重置用户状态(仅用户模块内的,全局清理走 resetAllState) function resetUserState(): void { AuthStorage.clearAuth(); userInfo.value = {} as UserInfo; } /** - * 会话失效的统一出口:通知用户、清理全局状态、携带当前路由跳转登录页 + * 会话失效的统一出口:清状态、整页跳登录页 * - * @param message 通知文案 - * @param notify 是否弹出通知 + * @param reason 跳转原因,写入 URL 的 reason 参数,登录页据此展示提示;不传则无提示 */ - async function redirectToLogin( - message: string = "请重新登录", - notify: boolean = true - ): Promise { - if (redirectingToLogin) return; - redirectingToLogin = true; + function redirectToLogin(reason?: RedirectReason): void { + if (sessionExpired) return; + sessionExpired = true; - try { - if (notify) { - ElNotification({ - title: "提示", - message, - type: "warning", - duration: 3000, - }); - } + // 携带当前路由,登录成功后跳回 + const currentPath = router.currentRoute.value.fullPath; + resetAllState(); - await resetAllState(); - - // 跳转到登录页,保留当前路由用于登录后跳转 - const currentPath = router.currentRoute.value.fullPath; - await router.push(`/login?redirect=${encodeURIComponent(currentPath)}`); - } catch (error) { - console.error("Redirect to login error:", error); - // 强制跳转,即使路由重定向失败 - window.location.href = "/login"; - } finally { - redirectingToLogin = false; - } + // 整页跳转:SPA 内 push 的导航被并发请求或守卫取消时不报错,页面会留在原地 + const reasonQuery = reason ? `&reason=${reason}` : ""; + window.location.href = `${window.location.pathname}#/login?redirect=${encodeURIComponent(currentPath)}${reasonQuery}`; + window.location.reload(); } - /** - * 刷新 token - */ + // 刷新 token async function doRefreshToken(): Promise { + if (refreshFailures >= MAX_REFRESH_FAILURES) { + throw new Error("令牌续期连续失败,请重新登录"); + } + const currentRefreshToken = AuthStorage.getRefreshToken(); if (!currentRefreshToken) { throw new Error("没有有效的刷新令牌"); } - const { accessToken, refreshToken: newRefreshToken } = - await AuthAPI.refreshToken(currentRefreshToken); - AuthStorage.setTokens(accessToken, newRefreshToken, AuthStorage.getRememberMe()); + try { + const { accessToken, refreshToken: newRefreshToken } = + await AuthAPI.refreshToken(currentRefreshToken); + AuthStorage.setTokens(accessToken, newRefreshToken, AuthStorage.getRememberMe()); + refreshFailures = 0; + } catch (error) { + refreshFailures += 1; + throw error; + } } return { @@ -167,8 +156,8 @@ export const useUserStore = defineStore("user", () => { resetAllState, resetUserState, redirectToLogin, - refreshToken: doRefreshToken, refreshTokenOnce, + waitRefresh, }; }); diff --git a/src/styles/page.scss b/src/styles/page.scss index 31f5e0bd..d3d8e5b4 100644 --- a/src/styles/page.scss +++ b/src/styles/page.scss @@ -217,3 +217,141 @@ gap: 8px; justify-content: flex-end; } + +/** + * 富文本内容排版(通知公告、协议说明等 v-html 渲染区域) + */ +.rich-text { + font-size: 14px; + line-height: 1.8; + color: var(--el-text-color-regular); + overflow-wrap: break-word; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + p { + margin: 0 0 10px; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 18px 0 10px; + font-weight: 600; + line-height: 1.4; + color: var(--el-text-color-primary); + } + + h1 { + font-size: 20px; + } + + h2 { + font-size: 18px; + } + + h3 { + font-size: 16px; + } + + h4, + h5, + h6 { + font-size: 14px; + } + + ul, + ol { + padding-left: 22px; + margin: 0 0 10px; + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + li { + margin-bottom: 4px; + } + + strong { + font-weight: 600; + color: var(--el-text-color-primary); + } + + a { + color: var(--el-color-primary); + + &:hover { + text-decoration: underline; + } + } + + img { + max-width: 100%; + height: auto; + border-radius: 6px; + } + + blockquote { + padding: 8px 14px; + margin: 12px 0; + color: var(--el-text-color-secondary); + background: var(--el-fill-color-light); + border-left: 3px solid var(--el-color-primary-light-5); + border-radius: 0 6px 6px 0; + } + + code { + padding: 2px 5px; + font-size: 12px; + color: var(--el-color-danger); + background: var(--el-fill-color-light); + border-radius: 4px; + } + + pre { + padding: 12px 14px; + overflow-x: auto; + background: var(--el-fill-color-light); + border-radius: 6px; + + code { + padding: 0; + color: inherit; + background: none; + } + } + + table { + width: 100%; + margin: 12px 0; + font-size: 13px; + border-collapse: collapse; + + th, + td { + padding: 8px 10px; + border: 1px solid var(--el-border-color-lighter); + } + + th { + font-weight: 600; + color: var(--el-text-color-primary); + background: var(--el-fill-color-light); + } + } +} diff --git a/src/styles/theme.scss b/src/styles/theme.scss index e4f51dc9..f1546e17 100644 --- a/src/styles/theme.scss +++ b/src/styles/theme.scss @@ -12,7 +12,7 @@ --menu-border: var(--card-border); --sidebar-logo-background: #fafafa; --sidebar-logo-text-color: #383853; - --page-bg: #f6f8fc; + --page-bg: #f2f4f7; --content-bg: #ffffff; --border-color: #e3eaf3; --card-border: #dfe7f1; diff --git a/src/utils/request.ts b/src/utils/request.ts index 961dd22f..61bc7a61 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -2,38 +2,37 @@ import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from "axio import qs from "qs"; import { ApiCodeEnum } from "@/enums/api"; -import { useUserStoreHook } from "@/stores/user"; import { usePermissionStoreHook } from "@/stores/permission"; import { AuthStorage } from "@/utils/auth"; import type { ApiResult } from "@/api/common"; - -// 防止同一请求在 token 刷新后重复进入重试,导致死循环 -const retriedRequests = new WeakSet(); +import { installTokenRefresh } from "@/utils/token-refresh"; const http = axios.create({ - baseURL: import.meta.env.VITE_APP_BASE_API, + baseURL: import.meta.env.VITE_API_BASE, timeout: 50000, headers: { "Content-Type": "application/json;charset=utf-8" }, - // 数组参数序列化为 ids=1&ids=2,而非 ids[]=1&ids[]=2 + // 数组参数序列化为 ids=1&ids=2,而非 ids[]=1&ids=2 paramsSerializer: (params) => qs.stringify(params, { arrayFormat: "repeat" }), }); +// 注入访问令牌 http.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - const token = AuthStorage.getAccessToken(); - - // 约定:调用方设置 Authorization 为 "no-auth" 即跳过 token 注入 - if (config.headers.Authorization === "no-auth") { - delete config.headers.Authorization; - } else if (token) { - config.headers.Authorization = `Bearer ${token}`; + if (!config.anonymous) { + const token = AuthStorage.getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } } - return config; }, (error) => Promise.reject(error) ); +// 令牌续期拦截器:响应拦截器按注册顺序执行,先于业务拦截器接管 401,业务拦截器只负责业务错误的提示 +installTokenRefresh(http); + +// 解包业务数据 http.interceptors.response.use( (response: AxiosResponse): AxiosResponse | any => { const { responseType } = response.config; @@ -54,7 +53,12 @@ http.interceptors.response.use( }, async (error) => { - const { config, response } = error; + // 令牌续期拦截器已终结的错误(Token Invalid 等)不再提示 + if (!axios.isAxiosError(error)) { + return Promise.reject(error); + } + + const { response } = error; if (!response) { ElMessage.error("网络连接失败"); @@ -63,38 +67,12 @@ http.interceptors.response.use( const { code, msg } = response.data as ApiResult; - // Token 过期 - if (code === ApiCodeEnum.ACCESS_TOKEN_INVALID) { - const userStore = useUserStoreHook(); - if (!config || retriedRequests.has(config)) { - await userStore.redirectToLogin("登录已过期,请重新登录"); - return Promise.reject(new Error("Token Invalid")); - } - - retriedRequests.add(config); - - try { - await userStore.refreshTokenOnce(); - - const token = AuthStorage.getAccessToken(); - if (token) { - config.headers.set("Authorization", `Bearer ${token}`); - } - - return http(config); - } catch { - await userStore.redirectToLogin("登录已过期,请重新登录"); - return Promise.reject(new Error("Token refresh failed")); - } + // 令牌失效:透传原始响应给续期拦截器处理,不在此提示 + if (code === ApiCodeEnum.ACCESS_TOKEN_INVALID || code === ApiCodeEnum.REFRESH_TOKEN_INVALID) { + return Promise.reject(error); } - // Refresh token 失效 - if (code === ApiCodeEnum.REFRESH_TOKEN_INVALID) { - await useUserStoreHook().redirectToLogin("登录已过期,请重新登录", false); - return Promise.reject(new Error("Token Invalid")); - } - - // 权限不足 + // 权限不足:刷新权限后再提示 if (code === ApiCodeEnum.PERMISSION_DENIED) { const permissionStore = usePermissionStoreHook(); await permissionStore.refreshPermissions(); diff --git a/src/utils/sse.ts b/src/utils/sse.ts index eb25a820..519f3109 100644 --- a/src/utils/sse.ts +++ b/src/utils/sse.ts @@ -3,7 +3,7 @@ import { useUserStoreHook } from "@/stores/user"; /** SSE 连接配置选项 */ export interface UseSseOptions { - /** SSE 连接地址,默认走 VITE_APP_BASE_API 代理 */ + /** SSE 连接地址,默认走 VITE_API_BASE 代理 */ url?: string; /** 是否在控制台打印调试日志 */ debug?: boolean; @@ -37,7 +37,7 @@ export enum SseConnectionState { let globalInstance: ReturnType | null = null; function createSseConnection(options: UseSseOptions = {}) { - const baseUrl = import.meta.env.VITE_APP_BASE_API; + const baseUrl = import.meta.env.VITE_API_BASE; const defaultUrl = `${baseUrl}/api/v1/sse/connect`; const config = { diff --git a/src/utils/token-refresh.ts b/src/utils/token-refresh.ts new file mode 100644 index 00000000..5bd0d239 --- /dev/null +++ b/src/utils/token-refresh.ts @@ -0,0 +1,81 @@ +import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios"; + +import { ApiCodeEnum } from "@/enums/api"; +import { useUserStoreHook } from "@/stores/user"; +import { AuthStorage } from "@/utils/auth"; +import type { ApiResult } from "@/api/common"; + +// 携带续期重试标记,mergeConfig 会把它带进重试后的新 config +type RetryRequestConfig = InternalAxiosRequestConfig & { isRetried?: boolean }; + +/** + * 注册令牌续期拦截器:过期自动续期并重试请求 + */ +export function installTokenRefresh(http: AxiosInstance) { + // 续期进行中时先等待完成,避免带旧令牌发出后再 401 重来;续期失败则继续用现有令牌 + http.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { + if (config.anonymous) { + return config; + } + + await useUserStoreHook() + .waitRefresh() + .catch(() => {}); + + const token = AuthStorage.getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; + }); + + http.interceptors.response.use(undefined, async (error: AxiosError) => { + const { config, response } = error; + + // 网络错误交回基础拦截器提示 + if (!response) { + return Promise.reject(error); + } + + const { code } = response.data ?? {}; + + // Token 过期 + if (code === ApiCodeEnum.ACCESS_TOKEN_INVALID) { + const retryConfig = config as RetryRequestConfig | undefined; + const userStore = useUserStoreHook(); + + // 匿名请求已重试过、或本就不参与续期:直接退出登录 + if (!retryConfig || retryConfig.isRetried || retryConfig.anonymous) { + await userStore.redirectToLogin("expired"); + return Promise.reject(new Error("Token Invalid")); + } + + retryConfig.isRetried = true; + + try { + await userStore.refreshTokenOnce(); + + const token = AuthStorage.getAccessToken(); + if (!token) { + throw new Error("续期未返回访问令牌"); + } + + retryConfig.headers.set("Authorization", `Bearer ${token}`); + return http(retryConfig); + } catch { + await userStore.redirectToLogin("expired"); + return Promise.reject(new Error("Token refresh failed")); + } + } + + // Refresh token 失效 + if (code === ApiCodeEnum.REFRESH_TOKEN_INVALID) { + await useUserStoreHook().redirectToLogin("expired"); + return Promise.reject(new Error("Token Invalid")); + } + + // 其它错误交回基础拦截器处理 + return Promise.reject(error); + }); +} diff --git a/src/views/codegen/components/BasicConfigStep.vue b/src/views/codegen/components/BasicConfigStep.vue index 24326cb1..1366a7fa 100644 --- a/src/views/codegen/components/BasicConfigStep.vue +++ b/src/views/codegen/components/BasicConfigStep.vue @@ -1,17 +1,13 @@ @@ -200,92 +194,55 @@ defineExpose({ validate }); .basic-config-step { padding: 8px; - .config-card { - padding: 24px; - margin-bottom: 20px; - background: var(--el-bg-color); - border: 1px solid var(--el-border-color-lighter); - border-radius: 12px; - transition: all 0.3s ease; + .config-section + .config-section { + padding-top: 24px; + margin-top: 24px; + border-top: 1px solid var(--el-border-color-lighter); + } - &:hover { - border-color: var(--el-border-color); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06); + .section-header { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 16px; + } + + .section-icon { + font-size: 16px; + + &.icon-table { + color: var(--el-color-primary); + } + &.icon-package { + color: var(--el-color-success); + } + &.icon-gen { + color: var(--el-color-warning); + } + } + + .section-title { + font-size: 15px; + font-weight: 600; + color: var(--el-text-color-primary); + } + + .section-subtitle { + font-size: 13px; + color: var(--el-text-color-secondary); + } + + .config-form { + :deep(.el-input__prefix-inner) { + color: var(--el-text-color-secondary); } - .card-header { - display: flex; - gap: 14px; + // 与默认输入框高度对齐,之前 10px 内边距把按钮撑得比输入框高 + :deep(.el-radio-button__inner) { + display: inline-flex; + gap: 4px; align-items: center; - padding-bottom: 16px; - margin-bottom: 20px; - border-bottom: 1px solid var(--el-border-color-lighter); - - .header-icon { - display: flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - font-size: 20px; - border-radius: 10px; - transition: transform 0.3s ease; - - &.icon-table { - color: var(--el-color-primary); - background: linear-gradient( - 135deg, - var(--el-color-primary-light-8), - var(--el-color-primary-light-9) - ); - } - &.icon-package { - color: var(--el-color-success); - background: linear-gradient( - 135deg, - var(--el-color-success-light-8), - var(--el-color-success-light-9) - ); - } - &.icon-gen { - color: var(--el-color-warning); - background: linear-gradient( - 135deg, - var(--el-color-warning-light-8), - var(--el-color-warning-light-9) - ); - } - } - - &:hover .header-icon { - transform: scale(1.08) rotate(-3deg); - } - - .header-title { - .title { - margin-bottom: 4px; - font-size: 16px; - font-weight: 600; - color: var(--el-text-color-primary); - } - .subtitle { - font-size: 13px; - color: var(--el-text-color-secondary); - } - } - } - - .card-form { - :deep(.el-input__prefix-inner) { - color: var(--el-text-color-secondary); - } - - :deep(.el-radio-button__inner) { - display: inline-flex; - gap: 4px; - align-items: center; - padding: 10px 20px; - } + padding: 6px 14px; } } } diff --git a/src/views/codegen/components/FieldConfigStep.vue b/src/views/codegen/components/FieldConfigStep.vue index 2409110f..d1007a62 100644 --- a/src/views/codegen/components/FieldConfigStep.vue +++ b/src/views/codegen/components/FieldConfigStep.vue @@ -1,46 +1,31 @@ @@ -238,23 +197,86 @@ import Sortable from "sortablejs"; import { FormTypeEnum, QueryTypeEnum } from "@/enums/codegen"; import type { GenConfigForm } from "@/api/codegen"; import type { OptionItem } from "@/api/common"; +import type { AiChangeItem } from "../composables/useAiFillDiff"; const formData = defineModel({ required: true }); -defineProps<{ +const props = defineProps<{ loading: boolean; loadingText: string; dictOptions?: OptionItem[]; + /** AI 改动明细:列名 → 各改动项 */ + aiChanges?: Record; }>(); const formTypeOptions: Record = FormTypeEnum; const queryTypeOptions: Record = QueryTypeEnum; -const tableRef = ref(); +const listRef = ref(); const sortFlag = ref(null); const fieldConfigs = computed(() => formData.value?.fieldConfigs || []); +// 只看 AI 改动过的字段 +const onlyAiChanged = ref(false); + +/** AI 改动过的字段数 */ +const aiChangedCount = computed(() => Object.keys(props.aiChanges ?? {}).length); + +/** 展示的字段,开启筛选时只保留 AI 改动过的字段 */ +const visibleFields = computed(() => { + if (!onlyAiChanged.value) return fieldConfigs.value; + return fieldConfigs.value.filter( + (row) => row.columnName && props.aiChanges?.[row.columnName]?.length + ); +}); + +/** 取某列的 AI 改动明细 */ +function aiChangesOf(columnName?: string) { + return columnName ? props.aiChanges?.[columnName] : undefined; +} + +/** 是否给卡片加改动底色;全部字段都被改过时不再整片高亮,那已经没有对比意义 */ +function isAiChanged(columnName?: string) { + const changed = aiChangesOf(columnName)?.length; + return !!changed && aiChangedCount.value < fieldConfigs.value.length; +} + +/** 只看 AI 改动过的字段,AI 填充完成后由父级调用 */ +function showOnlyAiChanged() { + if (!aiChangedCount.value) return; + onlyAiChanged.value = true; +} + +// 定位目标字段,短暂高亮用 +const locatedColumn = ref(""); +let locateTimer: ReturnType | undefined; + +/** 滚动到指定字段卡片并短暂高亮,供父级从 AI 改动明细定位时调用 */ +async function locateField(columnName: string) { + if (!columnName) return; + // 等字段列表按当前筛选渲染完,再取节点,否则可能定位到上一帧已不存在的卡片 + await nextTick(); + const cards = listRef.value?.querySelectorAll(".field-card"); + const target = Array.from(cards ?? []).find( + (card) => card.querySelector(".field-column")?.textContent?.trim() === columnName + ); + if (!target) return; + target.scrollIntoView({ behavior: "smooth", block: "center" }); + locatedColumn.value = columnName; + clearTimeout(locateTimer); + locateTimer = setTimeout(() => (locatedColumn.value = ""), 2000); +} + +// 筛选时关掉拖拽,避免显示的序号和真实数据错位 +watch(onlyAiChanged, (only) => { + if (only) { + destroySort(); + } else { + nextTick(() => initSort()); + } +}); + // 统计数量 const queryCount = computed(() => fieldConfigs.value.filter((f) => f.isShowInQuery === 1).length); const listCount = computed(() => fieldConfigs.value.filter((f) => f.isShowInList === 1).length); @@ -267,12 +289,11 @@ function bulkSet(key: "isShowInQuery" | "isShowInList" | "isShowInForm", value: }); } -// 用 Sortable.js 实现行拖拽排序,需要在字段配置步骤显示后调用 +// 用 Sortable.js 实现卡片拖拽排序,需要在字段配置步骤显示后调用 function initSort() { - if (sortFlag.value) return; - const tbody = tableRef.value?.$el?.querySelector(".el-table__body-wrapper tbody"); - if (!tbody) return; - sortFlag.value = Sortable.create(tbody, { + if (sortFlag.value || onlyAiChanged.value) return; + if (!listRef.value) return; + sortFlag.value = Sortable.create(listRef.value, { animation: 150, ghostClass: "sortable-ghost", handle: ".sortable-handle", @@ -293,10 +314,11 @@ function destroySort() { } // 暴露给父组件 -defineExpose({ initSort, destroySort }); +defineExpose({ initSort, destroySort, showOnlyAiChanged, locateField }); onBeforeUnmount(() => { destroySort(); + clearTimeout(locateTimer); }); @@ -306,72 +328,17 @@ onBeforeUnmount(() => { display: flex; gap: 16px; align-items: center; - padding: 16px 20px; + padding: 12px 16px; margin-bottom: 16px; + font-size: 13px; + color: var(--el-text-color-secondary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); - border-radius: 12px; + border-radius: 10px; - .stat-item { - display: flex; - gap: 10px; - align-items: center; - padding: 0 16px; - border-right: 1px solid var(--el-border-color-lighter); - - &:last-of-type { - border-right: none; - } - - .stat-icon { - display: flex; - align-items: center; - justify-content: center; - width: 38px; - height: 38px; - font-size: 18px; - color: #fff; - border-radius: 10px; - - &.bg-primary { - background: linear-gradient( - 135deg, - var(--el-color-primary), - var(--el-color-primary-light-3) - ); - } - &.bg-success { - background: linear-gradient( - 135deg, - var(--el-color-success), - var(--el-color-success-light-3) - ); - } - &.bg-warning { - background: linear-gradient( - 135deg, - var(--el-color-warning), - var(--el-color-warning-light-3) - ); - } - &.bg-info { - background: linear-gradient(135deg, var(--el-color-info), var(--el-color-info-light-3)); - } - } - - .stat-info { - .stat-value { - font-size: 20px; - font-weight: 700; - line-height: 1.2; - color: var(--el-text-color-primary); - } - .stat-label { - margin-top: 2px; - font-size: 12px; - color: var(--el-text-color-secondary); - } - } + b { + font-weight: 600; + color: var(--el-text-color-primary); } .bulk-actions { @@ -388,30 +355,119 @@ onBeforeUnmount(() => { } } - .field-table { - overflow: hidden; - border: 1px solid var(--el-border-color-lighter); - border-radius: 12px; + .field-cards { + display: flex; + flex-direction: column; + gap: 10px; + } - :deep(.el-table__header) { - th { - font-size: 13px; - font-weight: 600; - color: var(--el-text-color-primary); - background: var(--el-fill-color-light); + .field-card { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 10px 16px; + background: var(--el-bg-color); + border: 1px solid var(--el-border-color-lighter); + border-radius: 10px; + transition: border-color 0.2s ease; + + &:hover { + border-color: var(--el-border-color); + } + + // AI 改动过的字段:左侧标记 + 淡色底 + &.is-ai { + background: var(--el-color-primary-light-9); + border-color: var(--el-color-primary-light-7); + box-shadow: inset 3px 0 0 0 var(--el-color-primary); + } + + // 从 AI 改动明细定位过来:用 outline 描边,不和 is-ai 的 box-shadow 打架 + &.is-located { + outline: 2px solid var(--el-color-primary-light-5); + outline-offset: 1px; + border-color: var(--el-color-primary); + } + + // 列名与类型固定宽度,保证各字段纵向对齐好扫读 + .field-column { + flex: none; + width: 108px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; + font-weight: 600; + color: var(--el-text-color-primary); + white-space: nowrap; + } + + .field-meta { + flex: none; + width: 150px; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--el-font-family-mono, monospace); + font-size: 12px; + color: var(--el-text-color-secondary); + white-space: nowrap; + } + + .field-label { + font-size: 12px; + color: var(--el-text-color-secondary); + white-space: nowrap; + } + + .field-naming { + display: flex; + gap: 8px; + align-items: center; + padding-left: 12px; + border-left: 1px dashed var(--el-border-color-lighter); + + .field-input { + width: 120px; } } - :deep(.el-table__row) { - transition: background 0.2s ease; + .field-settings { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; + padding-left: 12px; + border-left: 1px dashed var(--el-border-color-lighter); - &:hover { - background: var(--el-fill-color-lighter) !important; + .setting-group { + display: flex; + gap: 8px; + align-items: center; + + // 查询、列表、表单三组之间用虚线分隔 + & + .setting-group { + padding-left: 16px; + border-left: 1px dashed var(--el-border-color-lighter); + } + + .field-required { + display: flex; + gap: 6px; + align-items: center; + } + } + + .field-select { + width: 118px; } } } } +.ai-change-line { + line-height: 1.7; +} + .sortable-ghost { background: var(--el-color-primary-light-9) !important; border: 1px dashed var(--el-color-primary); diff --git a/src/views/codegen/components/GeneratorDrawer.vue b/src/views/codegen/components/GeneratorDrawer.vue index 87dabf9c..f202d991 100644 --- a/src/views/codegen/components/GeneratorDrawer.vue +++ b/src/views/codegen/components/GeneratorDrawer.vue @@ -1,7 +1,14 @@