feat: 新增 AI 辅助,重构登录页与环境变量命名

This commit is contained in:
Ray.Hao
2026-09-17 14:59:55 +08:00
parent 085764e6b2
commit 75cbee2fa6
37 changed files with 1854 additions and 989 deletions

View File

@@ -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<unknown, LoginResult>({
url: `${AUTH_BASE_URL}/login`,
method: "post",
data: payload,
// 白名单接口携带过期令牌仍会被 Spring Security 判 401
anonymous: true,
});
},
/**
* 获取验证码图片
*/
getCaptcha() {
return request<unknown, CaptchaInfo>({
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<unknown, LoginResult>({
url: `${AUTH_BASE_URL}/switch-tenant`,
@@ -57,58 +64,54 @@ const AuthAPI = {
});
},
/**
* 用刷新令牌换取新的访问令牌
*/
refreshToken(refreshToken: string) {
return request<unknown, LoginResult>({
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<unknown, CaptchaInfo>({
url: `${AUTH_BASE_URL}/captcha`,
method: "get",
});
},
// ============ 扫码登录 ============
/**
* 生成扫码登录票据
*/
qrGenerate(): Promise<QrCodeGenerateResult> {
return request<unknown, QrCodeGenerateResult>({
url: `${AUTH_BASE_URL}/qr-code/generate`,
method: "post",
anonymous: true,
});
},
/**
* 轮询扫码登录状态
*/
qrStatus(ticket: string): Promise<QrCodeStatusResult> {
return request<unknown, QrCodeStatusResult>({
url: `${AUTH_BASE_URL}/qr-code/status`,
method: "get",
params: { ticket },
anonymous: true,
});
},
/**
* 用票据换取登录令牌
*/
qrLogin(ticket: string): Promise<LoginResult> {
return request<unknown, LoginResult>({
url: `${AUTH_BASE_URL}/qr-code/login`,
method: "post",
data: { ticket },
anonymous: true,
});
},
};
export default AuthAPI;
// 重导出类型
export * from "./types";

View File

@@ -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;
}

View File

@@ -17,7 +17,9 @@ const buildCodegenParams = (pageType?: "classic" | "curd", type?: "ts" | "js") =
};
const GeneratorAPI = {
/** 获取数据表分页列表 */
/**
* 获取数据表分页列表
*/
getTablePage(params: TableQueryParams) {
return request<unknown, PageResult<TableItem>>({
url: `${GENERATOR_BASE_URL}/table`,
@@ -26,7 +28,9 @@ const GeneratorAPI = {
});
},
/** 获取代码生成配置 */
/**
* 获取代码生成配置
*/
getGenConfig(tableName: string) {
return request<unknown, GenConfigForm>({
url: `${GENERATOR_BASE_URL}/${tableName}/config`,
@@ -34,7 +38,20 @@ const GeneratorAPI = {
});
},
/** 保存代码生成配置 */
/**
* AI 推断代码生成配置,未开启 AI 时返回业务异常
*/
aiFillConfig(tableName: string, requirement?: string) {
return request<unknown, GenConfigForm>({
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<unknown, GeneratorPreviewItem[]>({
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";

View File

@@ -21,6 +21,14 @@ const FormAPI = {
method: "get",
});
},
/** AI 生成表单规则(未开启 AI 时接口不存在) */
aiGenerate(description: string) {
return request<unknown, string>({
url: `${FORM_BASE_URL}/ai-generate`,
method: "post",
data: { description },
});
},
/** 表单定义分页列表 */
getPage(queryParams: FormDefinitionQueryParams) {
return request<unknown, PageResult<FormDefinitionItem>>({
@@ -68,8 +76,8 @@ const FormAPI = {
return request<unknown, FormRenderData>({
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,
});
},
/** 表单数据分页列表 */

View File

@@ -173,6 +173,13 @@ const TaskAPI = {
method: "get",
});
},
/** AI 生成任务审批摘要(未开启 AI 时接口不存在) */
aiSummary(taskId: string) {
return request<unknown, string>({
url: `${WORKFLOW_BASE_URL}/tasks/${taskId}/ai-summary`,
method: "post",
});
},
/** 获取驳回目标节点列表 */
listRejectTargets(taskId: string) {
return request<unknown, RejectTargetItem[]>({

1
src/assets/icons/ai.svg Normal file
View File

@@ -0,0 +1 @@
<svg t="1789521001207" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="30133" width="200" height="200"><path d="M442.688 163.84l-288 720A100.48 100.48 0 0 1 61.44 745.984l228.096-570.24a19.136 19.136 0 0 1 17.728-12.032h135.424z" p-id="30134"></path><path d="M604.16 504.128L480.256 192.192a44.992 44.992 0 0 0-41.792-28.416H320.576l135.232 340.352h148.416zM861.632 578.368h-148.352l122.176 305.408a100.48 100.48 0 0 0 93.248-137.728l-67.072-167.68zM632 572.032H483.648l150.528 311.744a93.12 93.12 0 0 0 83.84-133.568L632 572.032z" p-id="30135"></path><path d="M692.032 698.56a196.096 196.096 0 0 0-183.36-126.528h-50.112a126.528 126.528 0 0 0-126.592 126.528h360zM842.24 264.128l27.328 4.16c3.84 0.64 7.296 2.624 9.728 5.696l17.344 21.568a15.552 15.552 0 0 0 27.52-7.36l4.224-27.392c0.576-3.84 2.56-7.296 5.568-9.728l21.568-17.28a15.552 15.552 0 0 0-7.36-27.52l-27.328-4.288a15.616 15.616 0 0 1-9.728-5.568l-17.344-21.568a15.552 15.552 0 0 0-27.52 7.36l-4.224 27.328a15.552 15.552 0 0 1-5.568 9.728l-21.632 17.344a15.488 15.488 0 0 0-0.64 23.68 15.616 15.616 0 0 0 8 3.84zM701.952 173.376l-33.28-5.12a18.88 18.88 0 0 1-8.96-33.408l26.24-21.12a18.752 18.752 0 0 0 6.848-11.776l5.12-33.28a18.88 18.88 0 0 1 33.408-8.96l21.12 26.24a18.88 18.88 0 0 0 11.776 6.848l33.28 5.12c16.128 2.56 21.696 23.104 8.896 33.408l-26.24 21.056a18.88 18.88 0 0 0-6.784 11.84l-5.12 33.216a18.88 18.88 0 0 1-33.408 8.96l-21.12-26.24a18.88 18.88 0 0 0-11.776-6.784zM638.272 457.984l60.032 9.28c8.448 1.28 16 5.76 21.376 12.352l38.016 47.36c18.56 23.04 55.744 13.12 60.288-16.192l9.28-60.032a33.984 33.984 0 0 1 12.352-21.312l47.36-38.08a34.112 34.112 0 0 0-16.192-60.224l-60.032-9.28a34.048 34.048 0 0 1-21.312-12.352l-38.08-47.36a34.112 34.112 0 0 0-60.288 16.192l-9.28 60.032a34.048 34.048 0 0 1-12.288 21.312l-47.36 38.08c-23.04 18.56-13.12 55.68 16.128 60.224z" p-id="30136"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 900" preserveAspectRatio="none">
<defs>
<linearGradient id="darkBlue" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f1a33"/>
<stop offset="55%" stop-color="#0c1428"/>
<stop offset="100%" stop-color="#0a1020"/>
</linearGradient>
<pattern id="grid" width="72" height="72" patternUnits="userSpaceOnUse">
<path d="M72 0H0v72" fill="none" stroke="#ffffff" stroke-opacity=".05" stroke-width="1"/>
</pattern>
<linearGradient id="gridFade" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity=".9"/>
<stop offset="70%" stop-color="#ffffff" stop-opacity=".3"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<mask id="gridMask">
<rect width="1400" height="900" fill="url(#gridFade)"/>
</mask>
</defs>
<rect width="1400" height="900" fill="url(#darkBlue)"/>
<rect width="1400" height="900" fill="url(#grid)" mask="url(#gridMask)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 900" preserveAspectRatio="none">
<defs>
<!-- 白云蓝海渐变:白 → Arco 浅蓝 → #94bfff,整体清淡,朝气不阴暗 -->
<linearGradient id="sky" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="16%" stop-color="#f5faff"/>
<stop offset="32%" stop-color="#f2f8ff"/>
<stop offset="48%" stop-color="#eef6ff"/>
<stop offset="62%" stop-color="#e8f3ff"/>
<stop offset="76%" stop-color="#d6e8ff"/>
<stop offset="88%" stop-color="#bedaff"/>
<stop offset="100%" stop-color="#94bfff"/>
</linearGradient>
</defs>
<rect width="1400" height="900" fill="url(#sky)"/>
</svg>

Before

Width:  |  Height:  |  Size: 806 B

View File

@@ -67,29 +67,7 @@
</template>
</el-dropdown>
<el-dialog
v-model="dialogVisible"
:title="detail?.title ?? '通知详情'"
width="800px"
custom-class="notification-detail"
>
<div v-if="detail" class="p-x-20px">
<div class="flex-y-center mb-16px text-13px text-color-secondary">
<span class="flex-y-center">
<el-icon><User /></el-icon>
{{ detail.publisherName }}
</span>
<span class="ml-2 flex-y-center">
<el-icon><Timer /></el-icon>
{{ detail.publishTime }}
</span>
</div>
<div class="max-h-60vh pt-16px mb-24px overflow-y-auto border-t border-solid border-color">
<div v-html="detail.content"></div>
</div>
</div>
</el-dialog>
<NoticeDetailDialog v-model="dialogVisible" :detail="detail" />
</template>
<script setup lang="ts">

View File

@@ -0,0 +1,87 @@
<template>
<el-dialog v-model="visible" width="720px" align-center>
<template #header>
<div v-if="detail" class="notice-dialog__head">
<DictTag :model-value="detail.type" code="notice_type" size="small" />
<h3 class="notice-dialog__title">{{ detail.title }}</h3>
</div>
<div v-if="detail" class="notice-dialog__meta">
<span class="notice-dialog__meta-item">
<el-icon><User /></el-icon>
{{ detail.publisherName }}
</span>
<span class="notice-dialog__meta-item">
<el-icon><Timer /></el-icon>
{{ detail.publishTime }}
</span>
</div>
</template>
<div v-if="detail?.content" class="notice-dialog__content rich-text" v-html="detail.content" />
<el-empty v-else :image-size="60" description="暂无内容" />
</el-dialog>
</template>
<script setup lang="ts">
import type { NoticeDetail } from "@/api/system/notice";
defineOptions({
name: "NoticeDetailDialog",
});
defineProps<{
/** 通知详情数据 */
detail?: NoticeDetail | null;
}>();
/** 弹窗显示状态 */
const visible = defineModel<boolean>({ default: false });
</script>
<style lang="scss" scoped>
.notice-dialog {
&__head {
display: flex;
gap: 8px;
align-items: center;
padding-right: 40px;
}
&__title {
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
color: var(--el-text-color-primary);
white-space: nowrap;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__meta-item {
display: inline-flex;
gap: 4px;
align-items: center;
}
&__content {
max-height: 56vh;
padding: 16px 18px;
overflow-y: auto;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
}
</style>

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -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<void> {
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<void> {
const { accessToken, refreshToken } = await AuthAPI.qrLogin(ticket);
AuthStorage.setTokens(accessToken, refreshToken, false);
refreshFailures = 0;
sessionExpired = false;
}
let refreshPromise: Promise<void> | null = null;
/**
* 刷新 token(单飞模式)
*
* 多个并发请求遇到 token 过期时,共享同一次 refresh 请求。
* 刷新 token(单飞):并发请求共享同一次 refresh
*/
function refreshTokenOnce(): Promise<void> {
if (refreshPromise) return refreshPromise;
@@ -55,9 +61,12 @@ export const useUserStore = defineStore("user", () => {
return refreshPromise;
}
/**
* 获取用户信息
*/
// 等待进行中的续期;无续期时立即返回
function waitRefresh(): Promise<void> {
return refreshPromise ?? Promise.resolve();
}
// 获取用户信息
async function getUserInfo(): Promise<UserInfo> {
const data = await UserAPI.getInfo();
if (!data) {
@@ -67,19 +76,13 @@ export const useUserStore = defineStore("user", () => {
return data;
}
/**
* 登出
*/
// 登出
async function logout(): Promise<void> {
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<void> {
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<void> {
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,
};
});

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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<InternalAxiosRequestConfig>();
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<ApiResult>): 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();

View File

@@ -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<typeof createSseConnection> | 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 = {

View File

@@ -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<ApiResult>) => {
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);
});
}

View File

@@ -1,17 +1,13 @@
<template>
<div class="basic-config-step">
<!-- 表信息卡片 -->
<div class="config-card">
<div class="card-header">
<div class="header-icon icon-table">
<el-icon><Grid /></el-icon>
</div>
<div class="header-title">
<div class="title">表信息</div>
<div class="subtitle">数据库表名与业务映射</div>
</div>
<!-- 表信息 -->
<section class="config-section">
<div class="section-header">
<el-icon class="section-icon icon-table"><Document /></el-icon>
<span class="section-title">表信息</span>
<span class="section-subtitle">数据库表名与业务映射</span>
</div>
<el-form :model="formData" :rules="rules" :label-width="100" class="card-form">
<el-form :model="formData" :rules="rules" :label-width="100" class="config-form">
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="表名" prop="tableName">
@@ -33,20 +29,16 @@
</el-col>
</el-row>
</el-form>
</div>
</section>
<!-- 包信息卡片 -->
<div class="config-card">
<div class="card-header">
<div class="header-icon icon-package">
<el-icon><Box /></el-icon>
</div>
<div class="header-title">
<div class="title">包信息</div>
<div class="subtitle">Java 包结构与模块划分</div>
</div>
<!-- 包信息 -->
<section class="config-section">
<div class="section-header">
<el-icon class="section-icon icon-package"><Box /></el-icon>
<span class="section-title">包信息</span>
<span class="section-subtitle">Java 包结构与模块划分</span>
</div>
<el-form :model="formData" :rules="rules" :label-width="100" class="card-form">
<el-form :model="formData" :rules="rules" :label-width="100" class="config-form">
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="主包名" prop="packageName">
@@ -68,20 +60,22 @@
</el-col>
</el-row>
</el-form>
</div>
</section>
<!-- 生成配置卡片 -->
<div class="config-card">
<div class="card-header">
<div class="header-icon icon-gen">
<el-icon><MagicStick /></el-icon>
</div>
<div class="header-title">
<div class="title">生成配置</div>
<div class="subtitle">代码生成规则与输出选项</div>
</div>
<!-- 生成配置 -->
<section class="config-section">
<div class="section-header">
<el-icon class="section-icon icon-gen"><MagicStick /></el-icon>
<span class="section-title">生成配置</span>
<span class="section-subtitle">代码生成规则与输出选项</span>
</div>
<el-form ref="formRef" :model="formData" :rules="rules" :label-width="100" class="card-form">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
:label-width="100"
class="config-form"
>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="实体名" prop="entityName">
@@ -114,14 +108,14 @@
</el-col>
<el-col :span="12">
<el-form-item label="页面类型">
<el-radio-group v-model="formData.pageType" size="large">
<el-radio-group v-model="formData.pageType">
<el-radio-button value="classic">
<el-icon><DocumentChecked /></el-icon>
普通
</el-radio-button>
<el-radio-button value="curd">
<el-icon><SetUp /></el-icon>
封装(CURD)
封装(CRUD)
</el-radio-button>
</el-radio-group>
</el-form-item>
@@ -160,7 +154,7 @@
</el-col>
</el-row>
</el-form>
</div>
</section>
</div>
</template>
@@ -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;
}
}
}

View File

@@ -1,46 +1,31 @@
<template>
<div class="field-config-step">
<!-- 顶部统计栏 -->
<!-- 统计与批量操作 -->
<div class="stats-bar">
<div class="stat-item">
<div class="stat-icon bg-primary">
<el-icon><Tickets /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ fieldConfigs.length }}</div>
<div class="stat-label">字段总数</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon bg-success">
<el-icon><Search /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ queryCount }}</div>
<div class="stat-label">查询字段</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon bg-warning">
<el-icon><List /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ listCount }}</div>
<div class="stat-label">列表字段</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon bg-info">
<el-icon><EditPen /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ formCount }}</div>
<div class="stat-label">表单字段</div>
</div>
</div>
<span>
共
<b>{{ fieldConfigs.length }}</b>
个字段
</span>
<span>
查询
<b>{{ queryCount }}</b>
</span>
<span>
列表
<b>{{ listCount }}</b>
</span>
<span>
表单
<b>{{ formCount }}</b>
</span>
<!-- 批量操作 -->
<div class="bulk-actions">
<el-checkbox v-if="aiChangedCount" v-model="onlyAiChanged">
只看 AI 改动({{ aiChangedCount }})
</el-checkbox>
<el-divider v-if="aiChangedCount" direction="vertical" />
<span class="text-sm text-gray-500">批量:</span>
<el-dropdown @command="(cmd: any) => bulkSet(cmd.key, cmd.value)">
<el-button size="small" type="primary" plain>
@@ -89,147 +74,121 @@
</div>
</div>
<!-- 字段表格 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="fieldConfigs"
:element-loading-text="loadingText"
highlight-current-row
class="field-table"
>
<!-- 拖拽手柄 -->
<el-table-column width="48" align="center">
<template #default>
<el-icon class="cursor-move sortable-handle text-gray-400 hover:text-primary">
<!-- 字段卡片:取代原来的宽表,设置项在卡片内分行排布 -->
<div v-loading="loading" :element-loading-text="loadingText" class="field-cards-wrap">
<div ref="listRef" class="field-cards">
<div
v-for="row in visibleFields"
:key="row.columnName"
class="field-card"
:class="{
'is-ai': isAiChanged(row.columnName),
'is-located': locatedColumn === row.columnName,
}"
>
<el-icon class="sortable-handle cursor-move text-gray-400 hover:text-primary">
<Rank />
</el-icon>
</template>
</el-table-column>
<span class="field-column" :title="row.columnName">{{ row.columnName }}</span>
<el-tooltip v-if="aiChangesOf(row.columnName)?.length" placement="top" effect="light">
<template #content>
<div
v-for="item in aiChangesOf(row.columnName)"
:key="item.label"
class="ai-change-line"
>
{{ item.label }}:{{ item.from }} → {{ item.to }}
</div>
</template>
<el-tag size="small" type="primary" effect="plain">AI</el-tag>
</el-tooltip>
<span class="field-meta" :title="`${row.columnType} → ${row.fieldType}`">
{{ row.columnType }} → {{ row.fieldType }}
<span v-if="row.maxLength">({{ row.maxLength }})</span>
</span>
<!-- 字段信息 -->
<el-table-column label="字段信息" min-width="360">
<template #default="{ row }">
<div class="flex items-start gap-3">
<div class="field-info" style="flex-shrink: 0; width: 140px">
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{{ row.columnName }}</span>
<el-tag v-if="row.isPrimaryKey" size="small" type="warning" effect="dark">
主键
</el-tag>
</div>
<div class="text-xs text-gray-400 font-mono mt-1">
{{ row.columnType }} → {{ row.fieldType }}
<span v-if="row.maxLength">({{ row.maxLength }})</span>
</div>
<div class="field-naming">
<span class="field-label">字段名</span>
<el-input v-model="row.fieldName" size="small" class="field-input" />
<span class="field-label">注释</span>
<el-input v-model="row.fieldComment" size="small" class="field-input" />
</div>
<div class="field-settings">
<div class="setting-group">
<el-checkbox v-model="row.isShowInQuery" :true-value="1" :false-value="0">
查询
</el-checkbox>
<el-select
v-model="row.queryType"
:disabled="row.isShowInQuery !== 1"
size="small"
placeholder="未启用"
class="field-select"
>
<el-option
v-for="(item, key) in queryTypeOptions"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="flex-1 flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 w-10 text-right">字段名</span>
<el-input v-model="row.fieldName" size="small" style="width: 130px" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 w-10 text-right">注释</span>
<el-input v-model="row.fieldComment" size="small" style="width: 130px" />
<div class="setting-group">
<el-checkbox v-model="row.isShowInList" :true-value="1" :false-value="0">
列表
</el-checkbox>
</div>
<div class="setting-group">
<el-checkbox v-model="row.isShowInForm" :true-value="1" :false-value="0">
表单
</el-checkbox>
<el-select
v-model="row.formType"
:disabled="row.isShowInForm !== 1 && row.isShowInQuery !== 1"
size="small"
placeholder="未启用"
class="field-select"
>
<el-option
v-for="(item, key) in formTypeOptions"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-if="row.formType === FormTypeEnum.SELECT.value"
v-model="row.dictType"
clearable
size="small"
placeholder="字典"
class="field-select"
>
<el-option
v-for="item in dictOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="field-required">
<span class="field-label">必填</span>
<el-switch
v-model="row.isRequired"
:active-value="1"
:inactive-value="0"
:disabled="row.isShowInForm !== 1"
size="small"
/>
</div>
</div>
</div>
</template>
</el-table-column>
</div>
</div>
<!-- 查询 -->
<el-table-column label="查询" width="60" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.isShowInQuery" :true-value="1" :false-value="0" />
</template>
</el-table-column>
<!-- 查询方式 -->
<el-table-column label="查询方式" width="120">
<template #default="{ row }">
<el-select
v-model="row.queryType"
:disabled="row.isShowInQuery !== 1"
size="small"
placeholder=""
>
<el-option
v-for="(item, key) in queryTypeOptions"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<!-- 列表 -->
<el-table-column label="列表" width="60" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.isShowInList" :true-value="1" :false-value="0" />
</template>
</el-table-column>
<!-- 表单 -->
<el-table-column label="表单" width="60" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.isShowInForm" :true-value="1" :false-value="0" />
</template>
</el-table-column>
<!-- 表单类型 -->
<el-table-column label="表单类型" width="120">
<template #default="{ row }">
<el-select
v-model="row.formType"
:disabled="row.isShowInForm !== 1 && row.isShowInQuery !== 1"
size="small"
placeholder=""
>
<el-option
v-for="(item, key) in formTypeOptions"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<!-- 字典类型 -->
<el-table-column label="字典类型" width="120">
<template #default="{ row }">
<el-select
v-if="row.formType === FormTypeEnum.SELECT.value"
v-model="row.dictType"
clearable
size="small"
placeholder="请选择"
>
<el-option
v-for="item in dictOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span v-else class="text-gray-300 text-xs">-</span>
</template>
</el-table-column>
<!-- 必填 -->
<el-table-column label="必填" width="60" align="center">
<template #default="{ row }">
<el-switch
v-model="row.isRequired"
:active-value="1"
:inactive-value="0"
:disabled="row.isShowInForm !== 1"
size="small"
/>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!visibleFields.length" description="没有字段" :image-size="80" />
</div>
</div>
</template>
@@ -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<GenConfigForm>({ required: true });
defineProps<{
const props = defineProps<{
loading: boolean;
loadingText: string;
dictOptions?: OptionItem[];
/** AI 改动明细:列名 → 各改动项 */
aiChanges?: Record<string, AiChangeItem[]>;
}>();
const formTypeOptions: Record<string, OptionItem> = FormTypeEnum;
const queryTypeOptions: Record<string, OptionItem> = QueryTypeEnum;
const tableRef = ref();
const listRef = ref<HTMLElement>();
const sortFlag = ref<Sortable | null>(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<typeof setTimeout> | undefined;
/** 滚动到指定字段卡片并短暂高亮,供父级从 AI 改动明细定位时调用 */
async function locateField(columnName: string) {
if (!columnName) return;
// 等字段列表按当前筛选渲染完,再取节点,否则可能定位到上一帧已不存在的卡片
await nextTick();
const cards = listRef.value?.querySelectorAll<HTMLElement>(".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);
});
</script>
@@ -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);

View File

@@ -1,7 +1,14 @@
<template>
<el-drawer v-model="visible" :title="title" size="90%" destroy-on-close @close="handleClose">
<el-drawer
v-model="visible"
class="gen-drawer"
:title="title"
size="90%"
destroy-on-close
@close="handleClose"
>
<!-- 步骤导航 -->
<el-steps :active="currentStep" align-center finish-status="success">
<el-steps class="drawer-steps" :active="currentStep" align-center finish-status="success">
<el-step v-for="step in STEPS" :key="step.step">
<template #icon>
<el-icon :size="20"><component :is="step.icon" /></el-icon>
@@ -11,6 +18,48 @@
</el-step>
</el-steps>
<!-- AI 入口与结果合成一条:空闲时是入口,填充后是改动摘要与撤销;前端开关关闭时整体隐藏 -->
<div
v-if="currentStep !== STEP.PREVIEW && appConfig.aiEnabled"
class="ai-bar"
:class="{ 'is-filled': !!aiDiff }"
>
<span class="ai-bar-icon i-svg:ai" />
<div class="ai-bar-text">
<div class="ai-bar-title">
AI 智能推断
<span v-if="aiDiff" class="ai-bar-state">已填充,可撤销</span>
</div>
<div v-if="aiDiff" class="ai-bar-desc">
已推断
<b>{{ aiDiff.fieldCount }}</b>
个字段、
<b>{{ aiDiff.changeCount }}</b>
处改动
<span v-if="aiDiff.businessName" class="ai-bar-extra">
业务名 {{ aiDiff.businessName.from }} → {{ aiDiff.businessName.to }}
</span>
</div>
<div v-else class="ai-bar-desc">
{{
currentStep === STEP.BASIC_CONFIG
? "根据表结构自动补全业务名、字段描述和表单/查询类型"
: "根据字段定义自动补全字段描述、表单与查询组件"
}},结果可查看、可撤销
</div>
</div>
<div class="ai-bar-actions">
<el-button v-if="aiDiff" size="small" text type="primary" @click="aiDiffVisible = true">
查看改动
</el-button>
<el-button v-if="aiDiff" size="small" text @click="undoAiFill">撤销</el-button>
<el-button size="small" type="primary" :loading="aiLoading" @click="handleAiFill">
<template #icon><span class="i-svg:ai" /></template>
{{ aiDiff ? "重新推断" : "开始推断" }}
</el-button>
</div>
</div>
<!-- 步骤内容 -->
<div class="drawer-content mt-5">
<BasicConfigStep
@@ -27,6 +76,7 @@
:loading="loading"
:loading-text="loadingText"
:dict-options="dictOptions"
:ai-changes="fieldChanges"
/>
<PreviewStep
@@ -50,7 +100,7 @@
<!-- 底部操作栏 -->
<template #footer>
<div class="drawer-footer">
<div>
<div class="flex gap-3">
<el-button v-if="currentStep > STEP.BASIC_CONFIG" @click="handlePrev">
<el-icon><Back /></el-icon>
{{ STEPS[currentStep].prevText }}
@@ -94,14 +144,60 @@
@pick-backend-dir="pickBackendDir"
@confirm-write="confirmWrite"
/>
<!-- AI 改动明细 -->
<el-dialog v-model="aiDiffVisible" title="AI 改动明细" width="720px" append-to-body>
<div class="ai-diff-summary">
<span>共 {{ aiDiff?.fieldCount ?? 0 }} 个字段、{{ aiDiff?.changeCount ?? 0 }} 处改动</span>
<span v-if="aiDiff?.businessName">
业务名:{{ aiDiff.businessName.from }} → {{ aiDiff.businessName.to }}
</span>
<span class="ai-diff-hint">点击行选中字段,可定位到字段配置</span>
</div>
<el-table
:data="changeRows"
size="small"
max-height="420"
:span-method="diffSpanMethod"
highlight-current-row
class="ai-diff-table"
@row-click="handleDiffRowClick"
>
<el-table-column label="字段" width="200">
<template #default="{ row }">
<span class="ai-diff-field">{{ row.columnName }}</span>
<el-tag v-if="row.first" size="small" type="info" effect="plain">
{{ row.fieldChangeCount }} 处
</el-tag>
</template>
</el-table-column>
<el-table-column prop="label" label="改动项" width="110" />
<el-table-column label="变化">
<template #default="{ row }">
<span class="ai-diff-from">{{ row.from }}</span>
<el-icon class="ai-diff-arrow"><Right /></el-icon>
<span class="ai-diff-to">{{ row.to }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="aiDiffVisible = false">关闭</el-button>
<el-button @click="undoFromDiff">撤销 AI 填充</el-button>
<el-button type="primary" :disabled="!diffTargetColumn" @click="locateFromDiff">
定位到字段
</el-button>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup lang="ts">
import GeneratorAPI from "@/api/codegen";
import { appConfig } from "@/settings";
import { useGenConfig } from "../composables/useGenConfig";
import { useCodePreview } from "../composables/useCodePreview";
import { useLocalWrite } from "../composables/useLocalWrite";
import { useAiFillDiff, type AiChangeRow } from "../composables/useAiFillDiff";
const STEP = { BASIC_CONFIG: 0, FIELD_CONFIG: 1, PREVIEW: 2 } as const;
const STEPS = [
@@ -139,14 +235,46 @@ defineEmits<{ success: [] }>();
const currentStep = ref<number>(STEP.BASIC_CONFIG);
const currentTableName = ref("");
const loading = ref(false);
const aiLoading = ref(false);
const loadingText = ref("loading...");
const basicConfigRef = ref();
const fieldConfigRef = ref();
const previewRef = ref();
const { genConfigFormData, menuOptions, dictOptions, loadConfig, saveConfig, validateBasic } =
useGenConfig();
const {
genConfigFormData,
menuOptions,
dictOptions,
loadConfig,
saveConfig,
validateBasic,
applyDefaults,
} = useGenConfig();
const {
aiDiff,
fieldChanges,
changeRows,
clear: clearAiDiff,
snapshotConfig,
resolveDiff,
undo,
} = useAiFillDiff();
// AI 改动明细弹窗
const aiDiffVisible = ref(false);
// 明细中选中的字段,定位到字段配置时用
const diffTargetColumn = ref("");
// 每次打开清掉上次的选择,避免定位到上一轮残留的字段
watch(aiDiffVisible, (visible) => {
if (visible) diffTargetColumn.value = "";
});
function handleDiffRowClick(row: AiChangeRow) {
diffTargetColumn.value = row.columnName;
}
const {
filteredTreeData,
@@ -196,6 +324,8 @@ watch(currentStep, (val) => {
async function open(tableName: string) {
currentTableName.value = tableName;
currentStep.value = STEP.BASIC_CONFIG;
clearAiDiff();
aiDiffVisible.value = false;
loading.value = true;
try {
const config = await loadConfig(tableName);
@@ -217,7 +347,9 @@ async function handlePrev() {
genConfigFormData.value = { fieldConfigs: [] };
loading.value = true;
try {
genConfigFormData.value = await GeneratorAPI.getGenConfig(currentTableName.value);
genConfigFormData.value = applyDefaults(
await GeneratorAPI.getGenConfig(currentTableName.value)
);
} finally {
loading.value = false;
}
@@ -227,6 +359,74 @@ async function handlePrev() {
}
}
// AI 推断字段描述、表单/查询类型并回填,回填后跳到字段配置核对差异
async function handleAiFill() {
aiLoading.value = true;
loadingText.value = "AI 推断中,请稍候...";
try {
snapshotConfig(genConfigFormData.value);
const filled = await GeneratorAPI.aiFillConfig(currentTableName.value);
// 接口按表结构重建配置,页面类型等非字段项可能为空,沿用当前选择再补默认
filled.pageType = filled.pageType || genConfigFormData.value.pageType || "classic";
const diff = resolveDiff(filled);
genConfigFormData.value = applyDefaults(filled);
if (diff.changeCount === 0) {
ElMessage.warning("AI 未推断出可应用的改动");
return;
}
gotoFieldChanges();
} catch {
// 业务异常已由请求拦截器统一提示后端 message(含 AI 未开启的具体原因),此处不再重复
} finally {
aiLoading.value = false;
loadingText.value = "loading...";
}
}
// 跳到字段配置,并只显示 AI 改动过的字段
function gotoFieldChanges() {
currentStep.value = STEP.FIELD_CONFIG;
nextTick(() => fieldConfigRef.value?.showOnlyAiChanged());
}
// 同一字段的多条改动合并字段列,避免字段名反复出现
function diffSpanMethod({ rowIndex, columnIndex }: { rowIndex: number; columnIndex: number }) {
if (columnIndex !== 0) return;
const rows = changeRows.value;
const columnName = rows[rowIndex]?.columnName;
if (rowIndex > 0 && rows[rowIndex - 1]?.columnName === columnName) {
return { rowspan: 0, colspan: 0 };
}
let rowspan = 1;
while (rowIndex + rowspan < rows.length && rows[rowIndex + rowspan].columnName === columnName) {
rowspan++;
}
return { rowspan, colspan: 1 };
}
// 从明细弹窗定位到选中字段:切到字段配置并滚动高亮该字段
function locateFromDiff() {
const columnName = diffTargetColumn.value;
if (!columnName) return;
aiDiffVisible.value = false;
gotoFieldChanges();
nextTick(() => fieldConfigRef.value?.locateField(columnName));
}
// 恢复到 AI 填充前的配置
function undoAiFill() {
const config = undo();
if (!config) return;
genConfigFormData.value = config;
ElMessage.info("已撤销 AI 填充");
}
// 从明细弹窗撤销
function undoFromDiff() {
aiDiffVisible.value = false;
undoAiFill();
}
async function handleNext() {
if (currentStep.value === STEP.BASIC_CONFIG) {
if (!validateBasic()) return;
@@ -271,6 +471,17 @@ defineExpose({ open });
</script>
<style scoped lang="scss">
.gen-drawer {
// body 默认 20px 上内边距落在滚动容器内,吸顶的 AI 条上方会露出滚动内容,把它挪到步骤条上
:deep(.el-drawer__body) {
padding-top: 0;
}
}
.drawer-steps {
padding-top: 20px;
}
.drawer-content {
min-height: 400px;
}
@@ -279,4 +490,112 @@ defineExpose({ open });
align-items: center;
justify-content: space-between;
}
.ai-bar {
position: sticky;
top: 0;
z-index: 3;
display: flex;
gap: 12px;
align-items: center;
padding: 12px 16px;
margin-top: 16px;
background: linear-gradient(
135deg,
var(--el-color-primary-light-9),
var(--el-color-primary-light-8)
);
border-radius: 10px;
// 填充后加深描边,标记为结果条
&.is-filled {
border: 1px solid var(--el-color-primary-light-7);
}
.ai-bar-icon {
flex: none;
font-size: 28px;
color: var(--el-color-primary);
}
.ai-bar-text {
flex: 1;
min-width: 0;
}
.ai-bar-title {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.ai-bar-state {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.ai-bar-desc {
margin-top: 2px;
font-size: 13px;
color: var(--el-text-color-secondary);
b {
font-weight: 600;
color: var(--el-color-primary);
}
}
.ai-bar-extra {
margin-left: 8px;
}
.ai-bar-actions {
display: flex;
flex: none;
gap: 8px;
align-items: center;
}
}
.ai-diff-summary {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.ai-diff-hint {
margin-left: auto;
color: var(--el-text-color-placeholder);
}
.ai-diff-table {
:deep(.el-table__row) {
cursor: pointer;
}
}
.ai-diff-field {
margin-right: 6px;
font-weight: 600;
}
.ai-diff-from {
padding: 1px 6px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
border-radius: 4px;
}
.ai-diff-arrow {
margin: 0 6px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.ai-diff-to {
padding: 1px 6px;
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,167 @@
import { cloneDeep } from "lodash-es";
import type { FieldConfig, GenConfigForm } from "@/api/codegen";
import type { OptionItem } from "@/api/common";
import { FormTypeEnum, QueryTypeEnum } from "@/enums/codegen";
/** 单处改动 */
export interface AiChangeItem {
/** 改动项名称 */
label: string;
/** 改动前展示值 */
from: string;
/** 改动后展示值 */
to: string;
}
/** 单个字段的改动 */
export interface AiFieldChange {
/** 列名 */
columnName: string;
/** 改动明细 */
changes: AiChangeItem[];
}
/** 改动明细行,供差异表格逐行展示 */
export interface AiChangeRow extends AiChangeItem {
/** 列名 */
columnName: string;
/** 该字段的改动总数,只在首行展示 */
fieldChangeCount: number;
/** 是否为该字段的首行,字段名只在首行出现 */
first: boolean;
}
/** AI 填充前后差异 */
export interface AiFillDiff {
/** 业务名改动 */
businessName?: AiChangeItem;
/** 字段改动列表 */
fields: AiFieldChange[];
/** 涉及字段数 */
fieldCount: number;
/** 改动总数 */
changeCount: number;
}
/** 参与比对的字段属性,顺序与字段配置表格一致 */
const FIELD_ITEMS: { key: keyof FieldConfig; label: string; format: (value: unknown) => string }[] =
[
{ key: "fieldComment", label: "字段描述", format: text },
{ key: "isShowInQuery", label: "查询条件", format: switchText },
{ key: "queryType", label: "查询方式", format: (value) => enumLabel(QueryTypeEnum, value) },
{ key: "isShowInList", label: "列表显示", format: switchText },
{ key: "isShowInForm", label: "表单显示", format: switchText },
{ key: "formType", label: "表单类型", format: (value) => enumLabel(FormTypeEnum, value) },
{ key: "dictType", label: "字典类型", format: text },
{ key: "isRequired", label: "必填", format: switchText },
];
/** 空值显示为「空」,避免差异明细里出现空白 */
function text(value: unknown): string {
return value == null || value === "" ? "空" : String(value);
}
/** 0/1 开关值转文本 */
function switchText(value: unknown): string {
return value === 1 ? "是" : "否";
}
/** 枚举值转中文名,匹配不到时保留原值 */
function enumLabel(options: Record<string, OptionItem>, value: unknown): string {
if (value == null) return "-";
const hit = Object.values(options).find((item) => item.value === value);
return hit?.label ?? String(value);
}
/** 对比两份配置,只保留发生变化的部分 */
export function diffGenConfig(before: GenConfigForm, after: GenConfigForm): AiFillDiff {
const diff: AiFillDiff = { fields: [], fieldCount: 0, changeCount: 0 };
if ((before.businessName ?? "") !== (after.businessName ?? "")) {
diff.businessName = {
label: "业务名",
from: text(before.businessName),
to: text(after.businessName),
};
diff.changeCount++;
}
const beforeFields = new Map(
(before.fieldConfigs ?? []).map((field) => [field.columnName, field])
);
(after.fieldConfigs ?? []).forEach((field) => {
const origin = beforeFields.get(field.columnName);
if (!origin) return;
const changes: AiChangeItem[] = [];
FIELD_ITEMS.forEach(({ key, label, format }) => {
const from = format(origin[key]);
const to = format(field[key]);
if (from !== to) changes.push({ label, from, to });
});
if (changes.length) {
diff.fields.push({ columnName: field.columnName ?? "", changes });
diff.fieldCount++;
diff.changeCount += changes.length;
}
});
return diff;
}
/** AI 填充差异:填充前留快照,填充后算差异,支持撤销 */
export function useAiFillDiff() {
const aiDiff = ref<AiFillDiff | null>(null);
let snapshot: GenConfigForm | null = null;
/** 字段改动映射,供字段表格按列名标记 */
const fieldChanges = computed<Record<string, AiChangeItem[]>>(() => {
const map: Record<string, AiChangeItem[]> = {};
(aiDiff.value?.fields ?? []).forEach((field) => {
map[field.columnName] = field.changes;
});
return map;
});
/** 改动明细拍平成表格行,同一字段的多条改动带上合并标记 */
const changeRows = computed<AiChangeRow[]>(() =>
(aiDiff.value?.fields ?? []).flatMap((field) =>
field.changes.map((item, index) => ({
columnName: field.columnName,
fieldChangeCount: field.changes.length,
first: index === 0,
...item,
}))
)
);
/** 丢弃差异与快照 */
function clear() {
snapshot = null;
aiDiff.value = null;
}
/** 记录填充前的配置 */
function snapshotConfig(config: GenConfigForm) {
snapshot = cloneDeep(config);
aiDiff.value = null;
}
/** 计算与填充前的差异 */
function resolveDiff(filled: GenConfigForm): AiFillDiff {
const diff = snapshot
? diffGenConfig(snapshot, filled)
: { fields: [], fieldCount: 0, changeCount: 0 };
aiDiff.value = diff;
return diff;
}
/** 恢复填充前的配置并清空差异 */
function undo(): GenConfigForm | null {
if (!snapshot) return null;
const config = cloneDeep(snapshot);
clear();
return config;
}
return { aiDiff, fieldChanges, changeRows, clear, snapshotConfig, resolveDiff, undo };
}

View File

@@ -59,6 +59,14 @@ export function useGenConfig() {
{ deep: true, immediate: true }
);
/** 未保存过的表页面类型为空,统一补成普通表单 */
function applyDefaults(config: GenConfigForm): GenConfigForm {
if (!config.pageType) {
config.pageType = "classic";
}
return config;
}
/** 加载配置:并行获取菜单、字典、生成配置 */
async function loadConfig(tableName: string) {
const [menuList, dictList, config] = await Promise.all([
@@ -68,7 +76,7 @@ export function useGenConfig() {
]);
menuOptions.value = menuList;
dictOptions.value = dictList;
genConfigFormData.value = config;
genConfigFormData.value = applyDefaults(config);
return config;
}
@@ -105,5 +113,6 @@ export function useGenConfig() {
saveConfig,
validateBasic,
bulkSet,
applyDefaults,
};
}

View File

@@ -8,6 +8,10 @@
<span class="designer-toolbar__title">{{ title }}</span>
</div>
<div class="designer-toolbar__right">
<el-button @click="aiDialogVisible = true">
<template #icon><MagicStick /></template>
AI 生成
</el-button>
<el-button @click="handlePreview">预 览</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保 存</el-button>
</div>
@@ -16,12 +20,29 @@
<div class="designer-wrapper">
<FcDesigner ref="designerRef" :config="designerConfig" height="100%" />
</div>
<el-dialog v-model="aiDialogVisible" title="AI 生成表单" width="560px" append-to-body>
<el-input
v-model="aiDescription"
type="textarea"
:rows="5"
maxlength="1000"
show-word-limit
placeholder="描述需要的表单,例如:请假申请,包含姓名、请假类型、起止日期、请假事由(必填)、附件"
/>
<template #footer>
<el-button @click="aiDialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="aiGenerating" @click="handleAiGenerate">
生 成
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { ArrowLeft } from "@element-plus/icons-vue";
import { ArrowLeft, MagicStick } from "@element-plus/icons-vue";
import FcDesigner from "@form-create/designer";
@@ -49,6 +70,11 @@ const designerConfig = { showSaveBtn: false };
/** 保存中状态 */
const saving = ref(false);
/** AI 生成弹窗与需求描述 */
const aiDialogVisible = ref(false);
const aiDescription = ref("");
const aiGenerating = ref(false);
/** 表单定义元数据(保存时回传以满足后端非空校验) */
const formMeta = ref<{ formKey?: string; formName?: string }>({});
@@ -90,6 +116,26 @@ async function handleSave(): Promise<void> {
}
}
// AI 生成的规则直接覆盖画布,用户确认后再点保存
async function handleAiGenerate(): Promise<void> {
const description = aiDescription.value.trim();
if (!description) {
ElMessage.warning("请输入需求描述");
return;
}
aiGenerating.value = true;
try {
const formJson = await FormAPI.aiGenerate(description);
designerRef.value?.setRule(JSON.parse(formJson));
aiDialogVisible.value = false;
ElMessage.success("生成完成,请核对后保存");
} catch {
ElMessage.error("AI 生成失败,请确认已开启 AI 配置或调整描述");
} finally {
aiGenerating.value = false;
}
}
// 返回表单列表
function handleBack(): void {
router.back();

View File

@@ -16,13 +16,11 @@
</div>
<div class="login-brand__hero">
<div class="login-brand__main">
<el-tag class="login-brand__tag" type="primary" effect="plain" round>
<span class="login-brand__tag-dot" />
Enterprise Ready
</el-tag>
<h1 class="login-brand__title">开箱即用的企业级中后台解决方案</h1>
</div>
<el-tag class="login-brand__tag" type="primary" effect="plain" round>
<span class="login-brand__tag-dot" />
Enterprise Ready
</el-tag>
<h1 class="login-brand__title">开箱即用的企业级中后台解决方案</h1>
<div class="login-brand__features">
<div class="login-brand__feature">
<span class="login-brand__feature-mark">
@@ -48,6 +46,15 @@
<div class="login-card">
<div class="login-card__inner">
<el-alert
v-if="loginTip"
class="login-card__alert"
type="warning"
:closable="false"
show-icon
:title="loginTip"
/>
<transition name="fade-slide" mode="out-in">
<QrCodeLogin
v-if="component === 'qrcode'"
@@ -90,18 +97,18 @@
</el-tooltip>
<el-form-item prop="captchaCode">
<div class="captcha-row">
<div class="flex w-full gap-12px">
<el-input
v-model.trim="loginFormData.captchaCode"
placeholder="验证码"
class="captcha-row__input"
class="min-w-0 flex-1"
@keyup.enter="handleLoginSubmit"
>
<template #prefix>
<span class="input-prefix-icon i-svg:security" />
<span class="i-svg:security login-card__prefix-icon" />
</template>
</el-input>
<div class="captcha-img" @click="getCaptcha">
<div class="login-card__captcha" @click="getCaptcha">
<el-icon v-if="codeLoading" class="is-loading" :size="16">
<Loading />
</el-icon>
@@ -111,30 +118,32 @@
</div>
</el-form-item>
<div class="login-options">
<div class="login-card__options">
<el-checkbox v-model="loginFormData.rememberMe">记住我</el-checkbox>
<a class="login-options__link" @click="showForm('resetPwd')">忘记密码?</a>
<a @click="showForm('resetPwd')">忘记密码?</a>
</div>
<el-button
:loading="loading"
type="primary"
size="large"
class="login-btn"
class="login-card__submit"
@click="handleLoginSubmit"
>
登录
</el-button>
</el-form>
<div class="login-demo">
<div class="login-demo__title">工作流演示账号 · 密码统一 123456,点击直接填入</div>
<div class="login-demo__accounts">
<div class="login-card__demo">
<div class="login-card__demo-title">
工作流演示账号 · 密码统一 123456,点击直接填入
</div>
<div class="flex flex-wrap gap-8px">
<button
v-for="account in demoAccounts"
:key="account.username"
type="button"
class="login-demo__chip"
class="login-card__demo-chip"
@click="fillDemoAccount(account)"
>
{{ account.label }}
@@ -142,15 +151,15 @@
</div>
</div>
<div class="login-alt">
<div class="login-alt__divider">其他登录方式</div>
<div class="login-alt__buttons">
<button class="login-alt__btn" @click="component = 'qrcode'">
<span class="login-alt__icon i-svg:scan" />
<div class="login-card__alt">
<div class="login-card__alt-divider">其他登录方式</div>
<div class="flex gap-12px">
<button class="login-card__alt-btn" @click="component = 'qrcode'">
<span class="i-svg:scan login-card__alt-icon" />
扫码登录
</button>
<button class="login-alt__btn">
<span class="login-alt__icon i-svg:security" />
<button class="login-card__alt-btn">
<span class="i-svg:security login-card__alt-icon" />
统一认证
</button>
</div>
@@ -217,7 +226,18 @@ const loginRules = computed(() => ({
captchaCode: [{ required: true, trigger: "blur", message: "请输入验证码" }],
}));
/** 工作流演示账号(与 workflow.sql 预置数据一致,点击填入登录表单) */
// 会话失效跳转而来时,按 reason 参数展示对应提示
const loginTip = computed(() => {
const reasonMap: Record<string, string> = {
expired: "登录已过期,请重新登录",
"password-changed": "密码已修改,请重新登录",
};
const tip = reasonMap[route.query.reason as string];
if (!tip) return "";
return route.query.redirect ? `${tip},登录后将返回原页面` : tip;
});
// 工作流演示账号,与 workflow.sql 预置数据一致
const demoAccounts = [
{ username: "employee", label: "员工·发起" },
{ username: "dept_manager", label: "部门主管·审批" },
@@ -226,9 +246,6 @@ const demoAccounts = [
{ username: "clerk", label: "行政" },
];
/**
* 点击演示账号填入账号密码(验证码仍需手动输入)
*/
function fillDemoAccount(account: { username: string }): void {
loginFormData.value.username = account.username;
loginFormData.value.password = "123456";
@@ -279,18 +296,21 @@ onMounted(() => getCaptcha());
</script>
<style lang="scss" scoped>
$primary: #165dff;
$text-primary: #273248;
$text-secondary: #667085;
$text-muted: #98a2b3;
$input-h: 44px;
.login-page {
// 品牌色阶:从运行时主题色派生,切换主题色板(ArcoD/AntD/ElementD)时整页跟随
--brand-strong: color-mix(in srgb, var(--el-color-primary), #000 15%);
--brand-deep: color-mix(in srgb, var(--el-color-primary), #000 45%);
position: relative;
display: flex;
min-height: 100vh;
overflow: auto;
/* 浅色渐变底色;桌面端被品牌区背景图与白色卡片完全覆盖,窄屏两区透明后透出 */
/* 浅色渐变底色;桌面端被品牌区背景与白色卡片完全覆盖,窄屏两区透明后透出 */
background: linear-gradient(180deg, #ffffff 0%, #dbeafe 100%);
}
@@ -322,7 +342,18 @@ $input-h: 44px;
min-height: 100vh;
padding: 28px 64px 48px;
overflow: hidden;
background: url("@/assets/images/login/bg.svg") center / cover no-repeat;
// 白云蓝海渐变(原 bg.svg):白 → 主题色浅蓝,各 stop 按主题色混合比例派生
background: linear-gradient(
180deg,
#fff 0%,
color-mix(in srgb, var(--el-color-primary) 3%, #fff) 16%,
color-mix(in srgb, var(--el-color-primary) 5%, #fff) 32%,
color-mix(in srgb, var(--el-color-primary) 6%, #fff) 48%,
color-mix(in srgb, var(--el-color-primary) 8%, #fff) 62%,
color-mix(in srgb, var(--el-color-primary) 15%, #fff) 76%,
color-mix(in srgb, var(--el-color-primary) 25%, #fff) 88%,
color-mix(in srgb, var(--el-color-primary) 42%, #fff) 100%
);
animation: login-pane-in 0.36s ease-out both;
&__header,
@@ -342,8 +373,8 @@ $input-h: 44px;
width: 42px;
height: 42px;
padding: 5px;
background: rgb(37 99 235 / 8%);
border: 1px solid rgb(37 99 235 / 18%);
background: color-mix(in srgb, var(--el-color-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--el-color-primary) 18%, transparent);
border-radius: 12px;
}
@@ -358,7 +389,7 @@ $input-h: 44px;
font-size: 24px;
font-weight: 600;
line-height: 1;
color: #1e3a8a;
color: var(--brand-deep);
}
&__version {
@@ -369,9 +400,9 @@ $input-h: 44px;
font-size: 12px;
font-weight: 600;
line-height: 1;
color: rgb(30 58 138 / 80%);
background: rgb(37 99 235 / 8%);
border: 1px solid rgb(37 99 235 / 16%);
color: color-mix(in srgb, var(--brand-deep) 80%, transparent);
background: color-mix(in srgb, var(--el-color-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--el-color-primary) 16%, transparent);
border-radius: 999px;
}
@@ -384,20 +415,18 @@ $input-h: 44px;
padding: 40px 0 60px;
}
&__main {
width: 100%;
}
&__tag {
gap: 8px;
// 去掉 main 包装层后 tag 成为 hero 的 flex 子项,防止被 stretch 拉满宽度
align-self: flex-start;
height: 32px;
padding: 0 15px 0 13px;
margin-bottom: 20px;
font-size: 12px;
font-weight: 700;
color: #1d4ed8;
background: rgb(37 99 235 / 8%);
border-color: rgb(37 99 235 / 16%);
color: var(--brand-strong);
background: color-mix(in srgb, var(--el-color-primary) 8%, transparent);
border-color: color-mix(in srgb, var(--el-color-primary) 16%, transparent);
:deep(.el-tag__content) {
display: inline-flex;
@@ -411,9 +440,9 @@ $input-h: 44px;
flex-shrink: 0;
width: 7px;
height: 7px;
background: #2563eb;
background: var(--el-color-primary);
border-radius: 50%;
box-shadow: 0 0 0 3px rgb(37 99 235 / 16%);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--el-color-primary) 16%, transparent);
}
&__title {
@@ -421,7 +450,7 @@ $input-h: 44px;
font-size: 36px;
font-weight: 800;
line-height: 1.15;
color: #1e3a8a;
color: var(--brand-deep);
letter-spacing: 0;
}
@@ -450,7 +479,7 @@ $input-h: 44px;
padding: 0 15px;
font-size: 14px;
font-weight: 600;
color: rgb(30 58 138 / 85%);
color: color-mix(in srgb, var(--brand-deep) 85%, transparent);
background: transparent;
&:first-child {
@@ -464,7 +493,7 @@ $input-h: 44px;
width: 1px;
height: 14px;
content: "";
background: rgb(37 99 235 / 18%);
background: color-mix(in srgb, var(--el-color-primary) 18%, transparent);
}
}
@@ -474,9 +503,9 @@ $input-h: 44px;
justify-content: center;
width: 22px;
height: 22px;
color: #1d4ed8;
background: rgb(37 99 235 / 8%);
border: 1px solid rgb(37 99 235 / 16%);
color: var(--brand-strong);
background: color-mix(in srgb, var(--el-color-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--el-color-primary) 16%, transparent);
border-radius: 6px;
}
@@ -486,7 +515,7 @@ $input-h: 44px;
justify-content: center;
width: 14px;
height: 14px;
color: #1d4ed8;
color: var(--brand-strong);
}
&__feature-text {
@@ -521,6 +550,11 @@ $input-h: 44px;
width: 100%;
}
&__alert {
width: 100%;
margin-bottom: 16px;
}
&__title {
margin: 0 0 10px;
font-size: 28px;
@@ -535,6 +569,166 @@ $input-h: 44px;
font-size: 15px;
color: $text-muted;
}
&__prefix-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--el-text-color-placeholder);
}
&__captcha {
box-sizing: border-box;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 108px;
height: $input-h;
overflow: hidden;
cursor: pointer;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
transition: border-color 0.2s;
&:hover {
border-color: var(--el-color-primary);
}
img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
font-size: 14px;
color: $text-secondary;
a {
font-weight: 500;
color: var(--el-color-primary);
cursor: pointer;
transition: opacity 0.15s;
&:hover {
opacity: 0.8;
}
}
}
&__submit {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
box-shadow: 0 12px 24px color-mix(in srgb, var(--el-color-primary) 18%, transparent);
&:hover {
box-shadow: 0 14px 28px color-mix(in srgb, var(--el-color-primary) 22%, transparent);
}
&:focus,
&:focus-visible {
outline: none;
}
}
&__demo {
margin-top: 20px;
&-title {
margin-bottom: 10px;
font-size: 12px;
color: $text-muted;
}
&-chip {
height: 28px;
padding: 0 12px;
font-size: 12px;
color: $text-secondary;
cursor: pointer;
background: color-mix(in srgb, var(--el-color-primary) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--el-color-primary) 16%, transparent);
border-radius: 999px;
transition:
color 0.2s,
background 0.2s,
border-color 0.2s;
&:hover {
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--el-color-primary) 40%, transparent);
}
}
}
/* 半透明白底按钮,在渐变背景上保持可见;hover 时提实并加深投影 */
&__alt {
margin-top: 28px;
&-divider {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 14px;
font-size: 13px;
color: $text-muted;
&::before,
&::after {
flex: 1;
height: 1px;
content: "";
background: color-mix(in srgb, var(--brand-deep) 12%, transparent);
}
}
&-btn {
display: flex;
flex: 1;
gap: 8px;
align-items: center;
justify-content: center;
height: 44px;
padding: 0;
font-size: 14px;
color: $text-secondary;
cursor: pointer;
background: rgb(255 255 255 / 80%);
border: 1px solid color-mix(in srgb, var(--brand-deep) 10%, transparent);
border-radius: 8px;
box-shadow: 0 1px 2px color-mix(in srgb, var(--brand-deep) 6%, transparent);
backdrop-filter: blur(4px);
transition:
color 0.2s,
background 0.2s,
border-color 0.2s,
box-shadow 0.2s;
&:hover {
color: var(--el-color-primary);
background: #fff;
border-color: color-mix(in srgb, var(--el-color-primary) 35%, transparent);
box-shadow: 0 4px 12px color-mix(in srgb, var(--el-color-primary) 14%, transparent);
}
}
&-icon {
width: 16px;
height: 16px;
}
}
}
:deep(.el-form-item) {
@@ -545,188 +739,6 @@ $input-h: 44px;
height: $input-h;
}
.input-prefix-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--el-text-color-placeholder);
}
.captcha-row {
display: flex;
gap: 12px;
width: 100%;
}
.captcha-row__input {
flex: 1;
min-width: 0;
}
.captcha-img {
box-sizing: border-box;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 108px;
height: $input-h;
overflow: hidden;
cursor: pointer;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
transition: border-color 0.2s;
&:hover {
border-color: var(--el-color-primary);
}
img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
}
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
font-size: 14px;
color: $text-secondary;
&__link {
font-weight: 500;
color: $primary;
cursor: pointer;
transition: opacity 0.15s;
&:hover {
opacity: 0.8;
}
}
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
box-shadow: 0 12px 24px rgba($primary, 0.18);
&:hover {
box-shadow: 0 14px 28px rgba($primary, 0.22);
}
&:focus,
&:focus-visible {
outline: none;
}
}
.login-demo {
margin-top: 20px;
&__title {
margin-bottom: 10px;
font-size: 12px;
color: $text-muted;
}
&__accounts {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__chip {
height: 28px;
padding: 0 12px;
font-size: 12px;
color: $text-secondary;
cursor: pointer;
background: rgb(37 99 235 / 5%);
border: 1px solid rgb(37 99 235 / 16%);
border-radius: 999px;
transition:
color 0.2s,
background 0.2s,
border-color 0.2s;
&:hover {
color: $primary;
background: rgb(37 99 235 / 10%);
border-color: rgba($primary, 0.4);
}
}
}
.login-alt {
margin-top: 28px;
&__divider {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 14px;
font-size: 13px;
color: $text-muted;
&::before,
&::after {
flex: 1;
height: 1px;
content: "";
background: rgb(30 58 138 / 12%);
}
}
&__buttons {
display: flex;
gap: 12px;
}
/* 半透明白底按钮,在渐变背景上保持可见;hover 时提实并加深投影 */
&__btn {
display: flex;
flex: 1;
gap: 8px;
align-items: center;
justify-content: center;
height: 44px;
padding: 0;
font-size: 14px;
color: $text-secondary;
cursor: pointer;
background: rgb(255 255 255 / 80%);
border: 1px solid rgb(30 58 138 / 10%);
border-radius: 8px;
box-shadow: 0 1px 2px rgb(30 58 138 / 6%);
backdrop-filter: blur(4px);
transition:
color 0.2s,
background 0.2s,
border-color 0.2s,
box-shadow 0.2s;
&:hover {
color: $primary;
background: #fff;
border-color: rgba($primary, 0.35);
box-shadow: 0 4px 12px rgba($primary, 0.14);
}
}
&__icon {
width: 16px;
height: 16px;
}
}
.login-footer {
flex-shrink: 0;
font-size: 12px;
@@ -738,7 +750,25 @@ $input-h: 44px;
}
.dark .login-brand {
background-image: url("@/assets/images/login/bg-dark.svg");
// 深蓝渐变底(原 bg-dark.svg),暗色下不随主题色变化
background: linear-gradient(135deg, #0f1a33 0%, #0c1428 55%, #0a1020 100%);
// 网格纹理(原 bg-dark.svg pattern):白色细线 + 上浓下淡遮罩
&::before {
position: absolute;
inset: 0;
content: "";
background-image:
linear-gradient(rgb(255 255 255 / 5%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 5%) 1px, transparent 1px);
background-size: 72px 72px;
mask-image: linear-gradient(
to bottom,
rgb(0 0 0 / 90%) 0%,
rgb(0 0 0 / 30%) 70%,
transparent 100%
);
}
&__logo {
background: rgb(255 255 255 / 10%);
@@ -803,28 +833,12 @@ $input-h: 44px;
&__desc {
color: rgb(255 255 255 / 30%);
}
}
.dark .login-footer {
color: rgb(255 255 255 / 15%);
}
.dark .login-alt__divider {
color: rgb(255 255 255 / 20%);
&::before,
&::after {
background: rgb(255 255 255 / 12%);
}
}
/* 深色下演示账号用半透明白底 */
.dark .login-demo {
&__title {
&__demo-title {
color: rgb(255 255 255 / 35%);
}
&__chip {
&__demo-chip {
color: rgb(255 255 255 / 70%);
background: rgb(255 255 255 / 6%);
border-color: rgb(255 255 255 / 14%);
@@ -835,20 +849,32 @@ $input-h: 44px;
border-color: rgb(255 255 255 / 28%);
}
}
&__alt-divider {
color: rgb(255 255 255 / 20%);
&::before,
&::after {
background: rgb(255 255 255 / 12%);
}
}
&__alt-btn {
color: rgb(255 255 255 / 65%);
background: rgb(255 255 255 / 6%);
border-color: rgb(255 255 255 / 12%);
box-shadow: none;
&:hover {
color: #fff;
background: rgb(255 255 255 / 12%);
border-color: rgb(255 255 255 / 24%);
}
}
}
/* 深色下按钮用半透明白叠加,hover 提亮 */
.dark .login-alt__btn {
color: rgb(255 255 255 / 65%);
background: rgb(255 255 255 / 6%);
border-color: rgb(255 255 255 / 12%);
box-shadow: none;
&:hover {
color: #fff;
background: rgb(255 255 255 / 12%);
border-color: rgb(255 255 255 / 24%);
}
.dark .login-footer {
color: rgb(255 255 255 / 15%);
}
.fade-slide-enter-active,
@@ -896,9 +922,13 @@ $input-h: 44px;
}
}
/* 深色品牌区的背景图不适合窄屏,透出页面底色 */
/* 深色品牌区的渐变与网格纹理不适合窄屏,透出页面底色 */
.dark .login-brand {
background: none;
&::before {
display: none;
}
}
/* 卡片占满剩余高度并透出页面渐变,inner 沿用全局的垂直居中,页脚沉底 */

View File

@@ -855,7 +855,7 @@ const handleSubmit = async () => {
await UserAPI.changePassword(passwordChangeForm);
dialogState.visible = false;
await userStore.redirectToLogin("密码已修改,请重新登录");
await userStore.redirectToLogin("password-changed");
} else if (dialogState.type === DialogType.MOBILE) {
const valid = await mobileBindingFormRef.value?.validate();
if (!valid) return;

View File

@@ -82,35 +82,13 @@
/>
</el-card>
<el-dialog
v-model="noticeDialogVisible"
:title="noticeDetail?.title ?? '通知详情'"
width="800px"
custom-class="notice-detail"
>
<div v-if="noticeDetail" class="notice-detail__wrapper">
<div class="notice-detail__meta">
<span>
<el-icon><User /></el-icon>
{{ noticeDetail.publisherName }}
</span>
<span class="ml-2">
<el-icon><Timer /></el-icon>
{{ noticeDetail.publishTime }}
</span>
</div>
<div class="notice-detail__content">
<div v-html="noticeDetail.content"></div>
</div>
</div>
</el-dialog>
<NoticeDetailDialog v-model="noticeDialogVisible" :detail="noticeDetail" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Refresh, Search, Timer, User } from "@element-plus/icons-vue";
import { Refresh, Search } from "@element-plus/icons-vue";
import NoticeAPI from "@/api/system/notice";
import type { NoticeDetail, NoticeItem, NoticeQueryParams } from "@/api/system/notice";
@@ -157,43 +135,3 @@ onMounted(() => {
handleQuery();
});
</script>
<style lang="scss" scoped>
:deep(.el-dialog__header) {
text-align: center;
}
.notice-detail {
&__wrapper {
padding: 0 20px;
}
&__meta {
display: flex;
align-items: center;
margin-bottom: 16px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
&__publisher {
margin-right: 24px;
i {
margin-right: 4px;
}
}
&__content {
max-height: 60vh;
padding-top: 16px;
margin-bottom: 24px;
overflow-y: auto;
border-top: 1px solid var(--el-border-color);
&::-webkit-scrollbar {
width: 6px;
}
}
}
</style>

View File

@@ -301,7 +301,7 @@
{{ currentNotice.publishTime }}
</el-descriptions-item>
<el-descriptions-item label="公告内容:">
<div class="notice-content" v-html="currentNotice.content" />
<div class="notice-content rich-text" v-html="currentNotice.content" />
</el-descriptions-item>
</el-descriptions>
</el-dialog>

View File

@@ -92,6 +92,18 @@
<el-descriptions-item label="当前任务">{{ detail.taskName }}</el-descriptions-item>
</el-descriptions>
<!-- AI 摘要:按需生成,未开启 AI 时接口不存在,失败不影响审批 -->
<div class="todo-approve__ai">
<div class="todo-approve__ai-header">
<span>AI 摘要</span>
<el-button link type="primary" :loading="aiLoading" @click="handleAiSummary">
<template #icon><MagicStick /></template>
{{ aiSummary ? "重新生成" : "生成摘要" }}
</el-button>
</div>
<el-alert v-if="aiSummary" :closable="false" type="info" :description="aiSummary" />
</div>
<el-tabs class="todo-approve__tabs">
<el-tab-pane label="申请信息">
<FormDetail
@@ -165,7 +177,7 @@
<script setup lang="ts">
import { useFullscreen } from "@vueuse/core";
import { ElMessage, type FormInstance } from "element-plus";
import { Refresh } from "@element-plus/icons-vue";
import { MagicStick, Refresh } from "@element-plus/icons-vue";
import WorkflowAPI from "@/api/workflow";
import type {
@@ -225,6 +237,10 @@ const detail = ref<TaskDetailData>();
/** 审批意见(通过/驳回共用) */
const comment = ref("");
/** AI 摘要与生成状态 */
const aiSummary = ref("");
const aiLoading = ref(false);
/** 当前任务在流程走向中的位置(节点ID精确匹配,当前环节高亮进行中,之前为已完成;未匹配时不高亮) */
const currentStageIndex = computed(() => {
const stages = detail.value?.stages;
@@ -251,6 +267,7 @@ async function openApprove(taskId: string): Promise<void> {
approveState.loading = true;
closeRejectMode();
comment.value = "";
aiSummary.value = "";
try {
detail.value = await WorkflowAPI.task.getDetail(taskId);
approveState.title = `【${detail.value.taskName}】审批办理`;
@@ -259,6 +276,23 @@ async function openApprove(taskId: string): Promise<void> {
}
}
/**
* 生成当前任务的 AI 摘要
*/
async function handleAiSummary(): Promise<void> {
if (!detail.value?.taskId) {
return;
}
aiLoading.value = true;
try {
aiSummary.value = await WorkflowAPI.task.aiSummary(detail.value.taskId);
} catch {
ElMessage.error("AI 摘要生成失败,请确认已开启 AI 配置");
} finally {
aiLoading.value = false;
}
}
/**
* 切入驳回模式并加载可驳回的历史节点
*/
@@ -325,5 +359,19 @@ onMounted(() => {
margin-top: 16px;
margin-bottom: 8px;
}
&__ai {
margin-top: 12px;
&-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
}
</style>