feat: 新增 AI 辅助,重构登录页与环境变量命名
This commit is contained in:
1 parent
085764e6b2
commit
75cbee2fa6
37 files changed
+1854
-989
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+298
-268
@@ -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 沿用全局的垂直居中,页脚沉底 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user