AI代码生成工具
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
This commit is contained in:
@@ -83,3 +83,32 @@ export async function getTableStructureApi(tableName: string) {
|
||||
export async function generateFromTableApi(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}generate-from-table`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 提示词生成代码结构(自动填写表单)
|
||||
*/
|
||||
export async function aiGenerateApi(data: {
|
||||
module_name: string;
|
||||
description: string;
|
||||
pid?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模块名生成 / 优化需求概览与代码生成提示词
|
||||
* mode: generate=仅模块名生成;optimize=优化已有描述;不传则后端自动判断
|
||||
*/
|
||||
export async function aiRequirementPromptApi(data: {
|
||||
module_name: string;
|
||||
description?: string;
|
||||
mode?: 'generate' | 'optimize';
|
||||
}) {
|
||||
return requestClient.post<{
|
||||
text: string;
|
||||
prompt: string;
|
||||
overview: string;
|
||||
mode: string;
|
||||
module_name: string;
|
||||
}>(`${prefix}ai-requirement-prompt`, data);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
/**
|
||||
* AI 提示词模式:
|
||||
* 1) 可按模块名生成/优化「需求概览 + 代码生成提示词」并替换导入
|
||||
* 2) 再用提示词生成代码结构 → 预览确认 → 真正导入建表
|
||||
* 概览/提示词与代码结构都会写入后端 AI 生成历史
|
||||
*/
|
||||
import { nextTick, onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Card, Input, message, Select, TreeSelect } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Input,
|
||||
Tag,
|
||||
message,
|
||||
Select,
|
||||
TreeSelect,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { codeGenerationForm } from '#/views/code-generation/config/form';
|
||||
import {
|
||||
getAiGenerationDetail,
|
||||
getAiGenerationList,
|
||||
} from '#/views/system/config/api';
|
||||
import { getMenuTreeOption } from '#/views/system/menu/api';
|
||||
|
||||
import { aiGenerateApi, aiRequirementPromptApi } from '../api';
|
||||
import type { CodeGenData } from '../utils/data-transformer';
|
||||
import { transformAiToStandard } from '../utils/data-transformer';
|
||||
import { transformAiToStandard, validateData } from '../utils/data-transformer';
|
||||
import {
|
||||
deleteDraft,
|
||||
listDrafts,
|
||||
@@ -14,6 +37,7 @@ import {
|
||||
saveDraft,
|
||||
type GenerationMode,
|
||||
} from '../utils/draft-manager';
|
||||
import CodeGenerationFiledTable from './fileds.vue';
|
||||
|
||||
const mode: GenerationMode = 'ai';
|
||||
|
||||
@@ -22,27 +46,78 @@ const emit = defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
// 表单数据
|
||||
/** 步骤:prompt=填写描述;preview=代码结构预览确认 */
|
||||
const step = ref<'prompt' | 'preview'>('prompt');
|
||||
|
||||
const moduleName = ref('');
|
||||
const description = ref('');
|
||||
const pid = ref<number>(0);
|
||||
const loading = ref(false);
|
||||
const importing = ref(false);
|
||||
const requirementLoading = ref(false);
|
||||
|
||||
/** 需求文案预览(纯文本,可再次写入功能描述) */
|
||||
interface RequirementResult {
|
||||
text?: string;
|
||||
overview: string;
|
||||
prompt: string;
|
||||
mode: string;
|
||||
module_name?: string;
|
||||
}
|
||||
const requirementResult = ref<RequirementResult | null>(null);
|
||||
|
||||
const [Form, formApi] = useVbenForm(codeGenerationForm);
|
||||
const tableRef = ref<InstanceType<typeof CodeGenerationFiledTable> | null>(null);
|
||||
const previewRaw = ref<CodeGenData | null>(null);
|
||||
|
||||
// 菜单树数据
|
||||
const menuTreeData = ref<any[]>([]);
|
||||
const menuTreeLoading = ref(false);
|
||||
|
||||
// 草稿相关
|
||||
const draftId = ref<string | null>(null);
|
||||
const drafts = ref<ReturnType<typeof listDrafts>>([]);
|
||||
const selectedDraft = ref<string>('');
|
||||
|
||||
// 加载草稿列表
|
||||
/** 需求概览历史抽屉(scene=requirement_prompt) */
|
||||
const historyOpen = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const historyList = ref<any[]>([]);
|
||||
const historyDetailLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 加载草稿列表
|
||||
*/
|
||||
const loadDraftList = () => {
|
||||
drafts.value = listDrafts(mode);
|
||||
};
|
||||
|
||||
// 加载草稿
|
||||
/**
|
||||
* 持久化当前表单(含需求概览结果)到草稿
|
||||
*/
|
||||
const persistDraft = (extra: Record<string, any> = {}) => {
|
||||
try {
|
||||
const id = saveDraft(
|
||||
mode,
|
||||
{
|
||||
moduleName: moduleName.value,
|
||||
description: description.value,
|
||||
pid: pid.value,
|
||||
preview: previewRaw.value,
|
||||
requirement: requirementResult.value,
|
||||
...extra,
|
||||
},
|
||||
undefined,
|
||||
draftId.value || undefined,
|
||||
);
|
||||
draftId.value = id;
|
||||
loadDraftList();
|
||||
} catch {
|
||||
// 草稿失败不影响主流程
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载草稿到提示词表单
|
||||
*/
|
||||
const handleLoadDraft = (id: string) => {
|
||||
const draft = loadDraft(mode, id);
|
||||
if (draft && draft.data) {
|
||||
@@ -51,27 +126,26 @@ const handleLoadDraft = (id: string) => {
|
||||
moduleName.value = draft.data.moduleName || '';
|
||||
description.value = draft.data.description || '';
|
||||
pid.value = draft.data.pid ?? 0;
|
||||
requirementResult.value = draft.data.requirement || null;
|
||||
if (draft.data.preview) {
|
||||
applyPreview(draft.data.preview as CodeGenData);
|
||||
} else {
|
||||
step.value = 'prompt';
|
||||
}
|
||||
message.success('草稿已加载');
|
||||
}
|
||||
};
|
||||
|
||||
// 手动保存草稿
|
||||
/**
|
||||
* 手动保存提示词草稿
|
||||
*/
|
||||
const handleSaveDraft = () => {
|
||||
if (!moduleName.value.trim() && !description.value.trim()) {
|
||||
message.warning('请先填写内容');
|
||||
return;
|
||||
}
|
||||
|
||||
const draftData = {
|
||||
moduleName: moduleName.value,
|
||||
description: description.value,
|
||||
pid: pid.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const id = saveDraft(mode, draftData, undefined, draftId.value || undefined);
|
||||
draftId.value = id;
|
||||
loadDraftList();
|
||||
persistDraft();
|
||||
message.success('草稿已保存');
|
||||
} catch (error) {
|
||||
console.error('保存草稿失败:', error);
|
||||
@@ -79,37 +153,222 @@ const handleSaveDraft = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 发送AI请求
|
||||
/**
|
||||
* 把代码结构灌入预览表单与字段表
|
||||
*/
|
||||
const applyPreview = async (data: CodeGenData) => {
|
||||
previewRaw.value = data;
|
||||
step.value = 'preview';
|
||||
await nextTick();
|
||||
await formApi.setValues({
|
||||
class_name: data.class_name,
|
||||
class_comment: data.class_comment,
|
||||
icon: data.icon,
|
||||
sort: data.sort ?? 9999,
|
||||
pid: data.pid ?? 0,
|
||||
});
|
||||
if (tableRef.value) {
|
||||
tableRef.value.tableData = (data.field || []).map((f) => ({ ...f }));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成或优化需求概览与提示词
|
||||
* @param modeType generate=仅模块名;optimize=基于当前描述优化
|
||||
*/
|
||||
const handleRequirementPrompt = async (modeType: 'generate' | 'optimize') => {
|
||||
if (!moduleName.value.trim()) {
|
||||
message.warning('请先输入模块名');
|
||||
return;
|
||||
}
|
||||
// 优化但描述为空时,退化为「根据模块名生成」
|
||||
const actualMode: 'generate' | 'optimize' =
|
||||
modeType === 'optimize' && !description.value.trim() ? 'generate' : modeType;
|
||||
requirementLoading.value = true;
|
||||
try {
|
||||
const res = await aiRequirementPromptApi({
|
||||
module_name: moduleName.value.trim(),
|
||||
description: description.value.trim(),
|
||||
mode: actualMode,
|
||||
});
|
||||
requirementResult.value = res;
|
||||
// 纯文案直接写入功能描述
|
||||
const text = (res.text || res.prompt || res.overview || '').trim();
|
||||
if (text) {
|
||||
description.value = text.slice(0, 2000);
|
||||
}
|
||||
persistDraft({ requirement: res });
|
||||
message.success(
|
||||
actualMode === 'generate'
|
||||
? '已生成提示词文案并填入'
|
||||
: '已优化提示词文案并填入',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('需求概览生成失败:', error);
|
||||
} finally {
|
||||
requirementLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 再次把文案写入功能描述(一般生成后已自动填入)
|
||||
*/
|
||||
const handleReplaceImport = () => {
|
||||
if (!requirementResult.value) {
|
||||
message.warning('暂无可用文案');
|
||||
return;
|
||||
}
|
||||
const text = (
|
||||
requirementResult.value.text ||
|
||||
requirementResult.value.prompt ||
|
||||
requirementResult.value.overview ||
|
||||
''
|
||||
).trim();
|
||||
if (!text) {
|
||||
message.warning('内容为空,无法导入');
|
||||
return;
|
||||
}
|
||||
description.value = text.slice(0, 2000);
|
||||
persistDraft();
|
||||
message.success('已写入功能描述');
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开需求概览历史,支持从历史「替换导入」
|
||||
*/
|
||||
const openRequirementHistory = async () => {
|
||||
historyOpen.value = true;
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const res = await getAiGenerationList({
|
||||
scene: 'requirement_prompt',
|
||||
pageSize: 30,
|
||||
});
|
||||
historyList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载概览历史失败');
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从生成历史详情替换导入到当前表单
|
||||
*/
|
||||
const handleImportFromHistory = async (id: number) => {
|
||||
historyDetailLoading.value = true;
|
||||
try {
|
||||
const detail = await getAiGenerationDetail(id);
|
||||
let result = detail?.result_json;
|
||||
if (typeof result === 'string') {
|
||||
try {
|
||||
result = JSON.parse(result);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
if (!result?.text && !result?.prompt && !result?.overview) {
|
||||
message.warning('该记录没有可导入的文案');
|
||||
return;
|
||||
}
|
||||
const text = String(result.text || result.prompt || result.overview || '').trim();
|
||||
requirementResult.value = {
|
||||
text,
|
||||
overview: text,
|
||||
prompt: text,
|
||||
mode: result.mode || 'generate',
|
||||
module_name: result.module_name,
|
||||
};
|
||||
if (result.module_name && !moduleName.value.trim()) {
|
||||
moduleName.value = result.module_name;
|
||||
}
|
||||
if (text) {
|
||||
description.value = text.slice(0, 2000);
|
||||
}
|
||||
persistDraft();
|
||||
historyOpen.value = false;
|
||||
message.success('已从历史写入功能描述');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('导入失败');
|
||||
} finally {
|
||||
historyDetailLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送 AI 请求:生成代码结构,进入预览(不直接建表)
|
||||
*/
|
||||
const handleSend = async () => {
|
||||
if (!moduleName.value.trim()) {
|
||||
message.warning('请输入模块名');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description.value.trim()) {
|
||||
message.warning('请输入功能描述');
|
||||
message.warning('请输入功能描述(可先生成需求概览并替换导入)');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
// 模拟AI请求(实际开发中替换为真实API)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const aiData = await aiGenerateApi({
|
||||
module_name: moduleName.value.trim(),
|
||||
description: description.value.trim(),
|
||||
pid: pid.value ?? 0,
|
||||
});
|
||||
const standardData = transformAiToStandard(aiData);
|
||||
const validation = validateData(standardData);
|
||||
if (!validation.valid) {
|
||||
message.error(validation.message || 'AI 返回数据不合法,请重试或改描述');
|
||||
return;
|
||||
}
|
||||
persistDraft({ preview: standardData });
|
||||
await applyPreview(standardData);
|
||||
message.success('AI 已生成,请预览确认后再导入');
|
||||
} catch (error) {
|
||||
console.error('AI 生成失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
message.info('正在开发中~');
|
||||
// TODO: 实际开发时,这里应该调用AI API
|
||||
// const aiData = await aiGenerationApi({
|
||||
// moduleName: moduleName.value,
|
||||
// description: description.value,
|
||||
// pid: pid.value,
|
||||
// });
|
||||
// 处理AI返回的数据
|
||||
// const standardData = transformAiToStandard(aiData);
|
||||
// emit('generate', standardData);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 确认导入:真正执行 generationApi
|
||||
*/
|
||||
const handleConfirmImport = async () => {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
message.warning('请完善基础信息');
|
||||
return;
|
||||
}
|
||||
const formValues = await formApi.getValues();
|
||||
const fields = tableRef.value?.tableData || [];
|
||||
const finalData: CodeGenData = {
|
||||
class_name: formValues.class_name,
|
||||
class_comment: formValues.class_comment,
|
||||
icon: formValues.icon,
|
||||
sort: formValues.sort ?? 9999,
|
||||
pid: formValues.pid ?? 0,
|
||||
field: fields as CodeGenData['field'],
|
||||
};
|
||||
const validation = validateData(finalData);
|
||||
if (!validation.valid) {
|
||||
message.error(validation.message);
|
||||
return;
|
||||
}
|
||||
importing.value = true;
|
||||
try {
|
||||
emit('generate', finalData);
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToPrompt = () => {
|
||||
step.value = 'prompt';
|
||||
};
|
||||
|
||||
// 删除草稿
|
||||
const handleDeleteDraft = (id: string) => {
|
||||
if (deleteDraft(mode, id)) {
|
||||
message.success('草稿已删除');
|
||||
@@ -120,11 +379,13 @@ const handleDeleteDraft = (id: string) => {
|
||||
moduleName.value = '';
|
||||
description.value = '';
|
||||
pid.value = 0;
|
||||
previewRaw.value = null;
|
||||
requirementResult.value = null;
|
||||
step.value = 'prompt';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 加载菜单树
|
||||
const loadMenuTree = async () => {
|
||||
menuTreeLoading.value = true;
|
||||
try {
|
||||
@@ -146,7 +407,6 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="ai-prompt">
|
||||
<!-- 顶部操作栏 -->
|
||||
<Card class="mb-4">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -171,8 +431,17 @@ onMounted(() => {
|
||||
返回
|
||||
</Button>
|
||||
<span class="text-lg font-semibold">AI提示词模式</span>
|
||||
<span
|
||||
v-if="step === 'preview'"
|
||||
class="ml-2 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
|
||||
>
|
||||
预览确认
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button v-if="step === 'prompt'" @click="openRequirementHistory">
|
||||
<Icon icon="lucide:history" :size="14" class="mr-1" />概览历史
|
||||
</Button>
|
||||
<Select
|
||||
v-model:value="selectedDraft"
|
||||
placeholder="选择草稿"
|
||||
@@ -193,7 +462,7 @@ onMounted(() => {
|
||||
size="small"
|
||||
@click.stop="handleDeleteDraft(draft.id)"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<Icon icon="lucide:trash-2" :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</Select.Option>
|
||||
@@ -203,133 +472,221 @@ onMounted(() => {
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<Card class="content-area mb-24 shadow-sm">
|
||||
<div class="mb-6 rounded-lg bg-gradient-to-r from-blue-50 to-purple-50 p-4 dark:from-blue-900/20 dark:to-purple-900/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<i class="fa-solid fa-sparkles text-primary"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-1 font-semibold text-gray-900 dark:text-gray-100">AI智能生成</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
只需简单描述模块信息,AI将自动为您生成完整的代码结构
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fa-solid fa-cube text-primary"></i>
|
||||
<span>模块名</span>
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
v-model:value="moduleName"
|
||||
placeholder="请输入模块名称,例如:用户管理"
|
||||
size="large"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
建议使用简洁明了的名称,便于理解模块功能
|
||||
<template v-if="step === 'prompt'">
|
||||
<Card class="content-area mb-4 shadow-sm">
|
||||
<div
|
||||
class="mb-6 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--muted)/0.25)] p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10"
|
||||
>
|
||||
<Icon icon="lucide:sparkles" :size="16" class="text-primary" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-1 font-semibold text-[hsl(var(--foreground))]">
|
||||
AI智能生成
|
||||
</h4>
|
||||
<p class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
生成代码前可先点「根据模块名 / 优化提示词」完善功能描述,确认后才会建表
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fa-solid fa-file-lines text-primary"></i>
|
||||
<span>功能描述</span>
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Input.TextArea
|
||||
v-model:value="description"
|
||||
:rows="8"
|
||||
placeholder="请简单描述模块的功能,例如:管理用户的基本信息,包括用户的增删改查功能"
|
||||
show-count
|
||||
:maxlength="500"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
详细描述有助于AI更准确地生成代码结构
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-medium text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<Icon icon="lucide:box" :size="14" class="text-primary" />
|
||||
<span>模块名</span>
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
v-model:value="moduleName"
|
||||
placeholder="请输入模块名称,例如:VIP会员"
|
||||
size="large"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<label
|
||||
class="flex items-center gap-2 text-sm font-medium text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<Icon icon="lucide:file-text" :size="14" class="text-primary" />
|
||||
<span>功能描述</span>
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
:loading="requirementLoading"
|
||||
:disabled="!moduleName.trim()"
|
||||
@click="handleRequirementPrompt('generate')"
|
||||
>
|
||||
<Icon icon="lucide:sparkles" :size="14" class="mr-1" />
|
||||
根据模块名
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
:loading="requirementLoading"
|
||||
:disabled="!moduleName.trim()"
|
||||
@click="handleRequirementPrompt('optimize')"
|
||||
>
|
||||
<Icon icon="lucide:wand-sparkles" :size="14" class="mr-1" />
|
||||
优化提示词
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
v-model:value="description"
|
||||
:rows="8"
|
||||
placeholder="请简单描述模块的功能;也可点右侧「根据模块名 / 优化提示词」自动生成后直接填入"
|
||||
show-count
|
||||
:maxlength="2000"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
「根据模块名」仅需模块名;「优化提示词」会基于当前描述(为空则按模块名生成)优化并自动填入
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文案对照:已自动写入功能描述,可再点一次重新填入 -->
|
||||
<div
|
||||
v-if="requirementResult"
|
||||
class="requirement-preview rounded-xl border border-[hsl(var(--border))] p-4"
|
||||
>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-[hsl(var(--foreground))]">
|
||||
AI 文案预览
|
||||
</span>
|
||||
<Tag color="processing">
|
||||
{{
|
||||
requirementResult.mode === 'optimize' ? '优化' : '生成'
|
||||
}}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button type="primary" size="small" @click="handleReplaceImport">
|
||||
重新写入功能描述
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-pre-wrap rounded-lg bg-[hsl(var(--primary)/0.08)] p-3 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
{{
|
||||
requirementResult.text ||
|
||||
requirementResult.prompt ||
|
||||
requirementResult.overview
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-medium text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<Icon icon="lucide:network" :size="14" class="text-primary" />
|
||||
<span>父级菜单</span>
|
||||
</label>
|
||||
<TreeSelect
|
||||
v-model:value="pid"
|
||||
:tree-data="menuTreeData"
|
||||
:field-names="{
|
||||
label: 'title',
|
||||
value: 'id',
|
||||
children: 'children',
|
||||
}"
|
||||
:loading="menuTreeLoading"
|
||||
placeholder="请选择父级菜单(可选)"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
class="rounded-lg"
|
||||
tree-default-expand-all
|
||||
allow-clear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="mb-24"></div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fa-solid fa-sitemap text-primary"></i>
|
||||
<span>父级菜单</span>
|
||||
</label>
|
||||
<TreeSelect
|
||||
v-model:value="pid"
|
||||
:tree-data="menuTreeData"
|
||||
:field-names="{ label: 'title', value: 'id', children: 'children' }"
|
||||
:loading="menuTreeLoading"
|
||||
placeholder="请选择父级菜单(可选)"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
class="rounded-lg"
|
||||
tree-default-expand-all
|
||||
allow-clear
|
||||
/>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
选择后,生成的菜单将作为该菜单的子菜单
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<template v-else>
|
||||
<Card class="mb-4 shadow-sm" title="基础信息(可修改)">
|
||||
<Form />
|
||||
</Card>
|
||||
<Card class="mb-24 shadow-sm" title="字段预览(可增删改,确认后再导入)">
|
||||
<CodeGenerationFiledTable ref="tableRef" />
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<!-- 固定底部操作栏 -->
|
||||
<div class="fixed-bottom-bar">
|
||||
<div class="fixed-bottom-bar-wrapper">
|
||||
<div class="fixed-bottom-bar-content">
|
||||
<!-- 左侧状态信息 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full transition-all"
|
||||
:class="
|
||||
moduleName && description
|
||||
step === 'preview' || (moduleName && description)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600'
|
||||
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))]'
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
moduleName && description
|
||||
? 'fa-solid fa-sparkles'
|
||||
: 'fa-solid fa-circle-info'
|
||||
<Icon
|
||||
:icon="
|
||||
step === 'preview'
|
||||
? 'lucide:eye'
|
||||
: moduleName && description
|
||||
? 'lucide:sparkles'
|
||||
: 'lucide:info'
|
||||
"
|
||||
></i>
|
||||
:size="18"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm font-medium"
|
||||
:class="
|
||||
moduleName && description
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
"
|
||||
class="text-sm font-medium text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<span v-if="moduleName && description">
|
||||
信息已完整,可以发送AI请求
|
||||
<span v-if="step === 'preview'">
|
||||
请检查类名与字段,确认无误后再导入
|
||||
</span>
|
||||
<span v-else>请填写模块名和功能描述</span>
|
||||
<span v-else-if="moduleName && description">
|
||||
信息已完整,可以发送 AI 请求
|
||||
</span>
|
||||
<span v-else>可先生成需求概览,再填写/导入功能描述</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{
|
||||
moduleName && description
|
||||
? 'AI将根据您的描述生成代码结构'
|
||||
: '完整填写后即可使用AI生成功能'
|
||||
}}
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
<span v-if="step === 'preview'">
|
||||
导入后才会建表并生成前后端代码
|
||||
</span>
|
||||
<span v-else>概览/提示词会记入生成历史,可随时替换导入</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="large" class="action-btn-secondary" @click="emit('back')">
|
||||
<i class="fa-solid fa-times mr-2"></i>取消
|
||||
<Button
|
||||
v-if="step === 'prompt'"
|
||||
size="large"
|
||||
class="action-btn-secondary"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<Icon icon="lucide:x" :size="14" class="mr-2" />取消
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
size="large"
|
||||
class="action-btn-secondary"
|
||||
@click="handleBackToPrompt"
|
||||
>
|
||||
<Icon icon="lucide:arrow-left" :size="14" class="mr-2" />返回修改描述
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
@@ -337,9 +694,10 @@ onMounted(() => {
|
||||
:disabled="!moduleName && !description"
|
||||
@click="handleSaveDraft"
|
||||
>
|
||||
<i class="fa-solid fa-save mr-2"></i>保存草稿
|
||||
<Icon icon="lucide:save" :size="14" class="mr-2" />保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
v-if="step === 'prompt'"
|
||||
type="primary"
|
||||
size="large"
|
||||
class="action-btn-primary"
|
||||
@@ -347,41 +705,92 @@ onMounted(() => {
|
||||
:disabled="!moduleName.trim() || !description.trim()"
|
||||
@click="handleSend"
|
||||
>
|
||||
<i class="fa-solid fa-paper-plane mr-2"></i>发送AI请求
|
||||
<Icon icon="lucide:send" :size="14" class="mr-2" />发送AI请求
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
class="action-btn-primary"
|
||||
:loading="importing"
|
||||
@click="handleConfirmImport"
|
||||
>
|
||||
<Icon icon="lucide:file-input" :size="14" class="mr-2" />确认导入生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 需求概览历史:支持替换导入 -->
|
||||
<Drawer
|
||||
v-model:open="historyOpen"
|
||||
title="需求概览历史"
|
||||
:width="480"
|
||||
placement="right"
|
||||
>
|
||||
<div v-if="historyLoading" class="py-10 text-center text-[hsl(var(--muted-foreground))]">
|
||||
加载中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!historyList.length"
|
||||
class="py-10 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
暂无需求概览记录
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="item in historyList"
|
||||
:key="item.id"
|
||||
class="history-item rounded-xl border border-[hsl(var(--border))] p-3"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-[hsl(var(--foreground))]">
|
||||
{{ item.name || '未命名' }}
|
||||
</span>
|
||||
<Tag :color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'processing'">
|
||||
{{ item.status_text || '' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="mb-2 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{{ item.scene_text || item.scene }} · {{ item.created_at }}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
block
|
||||
:loading="historyDetailLoading"
|
||||
:disabled="item.status !== 1"
|
||||
@click="handleImportFromHistory(item.id)"
|
||||
>
|
||||
替换导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-prompt {
|
||||
animation: fade-slide-enter 0.3s ease-out;
|
||||
padding-bottom: 120px; /* 为固定底部栏留出空间 */
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
min-height: 500px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
/* 优化卡片样式 */
|
||||
:deep(.ant-card) {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid hsl(var(--border));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.ant-card:hover) {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
.requirement-preview {
|
||||
background: hsl(var(--card, var(--background)));
|
||||
}
|
||||
|
||||
.dark :deep(.ant-card) {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 固定底部操作栏 - 现代化设计 */
|
||||
.fixed-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@@ -395,21 +804,14 @@ onMounted(() => {
|
||||
.fixed-bottom-bar-wrapper {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
background: hsl(var(--card, var(--background)) / 0.98);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 -8px 32px hsl(var(--foreground) / 0.08);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.dark .fixed-bottom-bar-wrapper {
|
||||
background: rgba(31, 41, 55, 0.98);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fixed-bottom-bar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -418,33 +820,20 @@ onMounted(() => {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.action-btn-secondary {
|
||||
.action-btn-secondary,
|
||||
.action-btn-primary {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn-secondary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-btn-primary {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
padding: 0 24px;
|
||||
height: 44px;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.4);
|
||||
}
|
||||
|
||||
.action-btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
.history-item {
|
||||
background: hsl(var(--card, var(--background)));
|
||||
}
|
||||
|
||||
@keyframes fade-slide-enter {
|
||||
@@ -458,16 +847,11 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.fixed-bottom-bar {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.fixed-bottom-bar-wrapper {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.fixed-bottom-bar-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -475,10 +859,6 @@ onMounted(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fixed-bottom-bar-content > div:first-child {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fixed-bottom-bar-content > div:last-child {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -490,4 +870,3 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,57 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 数据库表模式:卡片点击选表(不用下拉),再预览字段并生成代码
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Card, Input, message, Select, Table } from 'ant-design-vue';
|
||||
import { Button, Card, Input, Spin, Table, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { codeGenerationForm } from '#/views/code-generation/config/form';
|
||||
|
||||
import type { CodeGenData } from '../utils/data-transformer';
|
||||
import { validateData } from '../utils/data-transformer';
|
||||
import {
|
||||
generateFromTableApi,
|
||||
getTableStructureApi,
|
||||
getTablesApi,
|
||||
} from '../api';
|
||||
import { getTableStructureApi, getTablesApi } from '../api';
|
||||
|
||||
const emit = defineEmits<{
|
||||
generate: [data: CodeGenData];
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
// 表单相关
|
||||
const [Form, formApi] = useVbenForm(codeGenerationForm);
|
||||
const formData = ref<Record<string, any>>({
|
||||
class_name: '',
|
||||
class_comment: '',
|
||||
icon: '',
|
||||
sort: 9999,
|
||||
pid: 0,
|
||||
});
|
||||
|
||||
// 表列表
|
||||
const tableList = ref<any[]>([]);
|
||||
const tableLoading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const selectedTable = ref<string>('');
|
||||
|
||||
// 表结构数据
|
||||
const tableStructure = ref<CodeGenData | null>(null);
|
||||
const structureLoading = ref(false);
|
||||
|
||||
// 过滤后的表列表
|
||||
/** 按关键字过滤表名/注释 */
|
||||
const filteredTableList = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return tableList.value;
|
||||
}
|
||||
const kw = searchKeyword.value.toLowerCase();
|
||||
return tableList.value.filter(
|
||||
(table) =>
|
||||
table.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
|
||||
(table.comment && table.comment.toLowerCase().includes(searchKeyword.value.toLowerCase())),
|
||||
table.name.toLowerCase().includes(kw) ||
|
||||
(table.comment && String(table.comment).toLowerCase().includes(kw)),
|
||||
);
|
||||
});
|
||||
|
||||
// 加载表列表
|
||||
/**
|
||||
* 加载库表列表
|
||||
*/
|
||||
const loadTableList = async () => {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
@@ -65,54 +57,40 @@ const loadTableList = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 选择表
|
||||
/**
|
||||
* 点击卡片选择表并拉取结构
|
||||
*/
|
||||
const handleSelectTable = async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
selectedTable.value = '';
|
||||
tableStructure.value = null;
|
||||
formApi.resetFields();
|
||||
if (!tableName || selectedTable.value === tableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTable.value = tableName;
|
||||
structureLoading.value = true;
|
||||
|
||||
try {
|
||||
// 获取表结构
|
||||
const structureRes = await getTableStructureApi(tableName);
|
||||
const fields = structureRes || [];
|
||||
|
||||
// 生成类名(从表名转换)
|
||||
const fields = (await getTableStructureApi(tableName)) || [];
|
||||
const prefix = 'nl_';
|
||||
let className = tableName.replace(prefix, '');
|
||||
className = className
|
||||
.split('_')
|
||||
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
|
||||
// 获取表注释作为中文名称
|
||||
const tableInfo = tableList.value.find((t) => t.name === tableName);
|
||||
const classComment = tableInfo?.comment || tableName;
|
||||
|
||||
// 构建代码生成数据
|
||||
tableStructure.value = {
|
||||
class_name: className,
|
||||
class_comment: classComment,
|
||||
icon: '',
|
||||
icon: 'lucide:database',
|
||||
sort: 9999,
|
||||
pid: 0,
|
||||
field: fields,
|
||||
};
|
||||
|
||||
// 更新表单数据
|
||||
formData.value = {
|
||||
await formApi.setValues({
|
||||
class_name: className,
|
||||
class_comment: classComment,
|
||||
icon: '',
|
||||
icon: 'lucide:database',
|
||||
sort: 9999,
|
||||
pid: 0,
|
||||
};
|
||||
formApi.setValues(formData.value);
|
||||
});
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取表结构失败');
|
||||
tableStructure.value = null;
|
||||
@@ -121,59 +99,36 @@ const handleSelectTable = async (tableName: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 生成代码
|
||||
/**
|
||||
* 确认生成代码
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedTable.value) {
|
||||
if (!selectedTable.value || !tableStructure.value) {
|
||||
message.warning('请先选择数据库表');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tableStructure.value) {
|
||||
message.warning('表结构数据未加载,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证数据
|
||||
const validation = validateData(tableStructure.value);
|
||||
if (!validation.valid) {
|
||||
message.error(validation.message);
|
||||
return;
|
||||
// 表结构可能缺 icon,用表单值补齐后再验
|
||||
}
|
||||
|
||||
// 合并表单数据(用户可能修改了基础信息)
|
||||
const formValues = await formApi.getValues();
|
||||
const finalData: CodeGenData = {
|
||||
...tableStructure.value,
|
||||
class_name: formValues.class_name || tableStructure.value.class_name,
|
||||
class_comment: formValues.class_comment || tableStructure.value.class_comment,
|
||||
icon: formValues.icon || tableStructure.value.icon,
|
||||
class_comment:
|
||||
formValues.class_comment || tableStructure.value.class_comment,
|
||||
icon: formValues.icon || tableStructure.value.icon || 'lucide:database',
|
||||
sort: formValues.sort ?? tableStructure.value.sort,
|
||||
pid: formValues.pid ?? tableStructure.value.pid,
|
||||
};
|
||||
|
||||
const finalCheck = validateData(finalData);
|
||||
if (!finalCheck.valid) {
|
||||
message.error(finalCheck.message);
|
||||
return;
|
||||
}
|
||||
emit('generate', finalData);
|
||||
};
|
||||
|
||||
// 表列配置
|
||||
const tableColumns = [
|
||||
{
|
||||
title: '表名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '注释',
|
||||
dataIndex: 'comment',
|
||||
key: 'comment',
|
||||
},
|
||||
{
|
||||
title: '记录数',
|
||||
dataIndex: 'rows',
|
||||
key: 'rows',
|
||||
width: 100,
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
loadTableList();
|
||||
});
|
||||
@@ -181,94 +136,97 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="database-select">
|
||||
<!-- 顶部操作栏 -->
|
||||
<Card class="mb-4">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button type="text" @click="emit('back')">
|
||||
<svg
|
||||
class="mr-2 inline-block"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.75 3.5L5.25 7L8.75 10.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<Icon icon="lucide:chevron-left" :size="14" class="mr-1" />
|
||||
返回
|
||||
</Button>
|
||||
<span class="text-lg font-semibold">数据库表模式</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mb-4">
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索表名或注释..."
|
||||
allow-clear
|
||||
class="mb-4"
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索表名或注释..."
|
||||
allow-clear
|
||||
class="mb-4"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="lucide:search" :size="14" />
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<Spin :spinning="tableLoading">
|
||||
<div
|
||||
v-if="filteredTableList.length"
|
||||
class="table-card-grid mb-2"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fa-solid fa-search"></i>
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
选择数据库表:
|
||||
</div>
|
||||
<Select
|
||||
v-model:value="selectedTable"
|
||||
placeholder="请选择数据库表"
|
||||
show-search
|
||||
:filter-option="false"
|
||||
style="width: 100%"
|
||||
:loading="tableLoading"
|
||||
@change="handleSelectTable"
|
||||
<button
|
||||
v-for="table in filteredTableList"
|
||||
:key="table.name"
|
||||
type="button"
|
||||
class="table-card"
|
||||
:class="{ 'is-active': selectedTable === table.name }"
|
||||
@click="handleSelectTable(table.name)"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="table in filteredTableList"
|
||||
:key="table.name"
|
||||
:value="table.name"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ table.name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">
|
||||
{{ table.comment || '-' }}
|
||||
</span>
|
||||
<div class="table-card__icon">
|
||||
<Icon icon="lucide:table-2" :size="20" />
|
||||
</div>
|
||||
<div class="table-card__body">
|
||||
<div class="table-card__name">{{ table.name }}</div>
|
||||
<div class="table-card__comment">
|
||||
{{ table.comment || '无注释' }}
|
||||
</div>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<div v-if="table.rows != null" class="table-card__meta">
|
||||
{{ table.rows }} 行
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="selectedTable === table.name"
|
||||
icon="lucide:check-circle-2"
|
||||
:size="18"
|
||||
class="table-card__check"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-xl border border-dashed border-[hsl(var(--border))] py-12 text-center text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{{ searchKeyword ? '无匹配的数据表' : '暂无数据表' }}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<div v-if="selectedTable" class="rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
|
||||
<i class="fa-solid fa-info-circle mr-2"></i>
|
||||
已选择表:<strong>{{ selectedTable }}</strong>
|
||||
<span v-if="tableStructure">
|
||||
,共 {{ tableStructure.field?.length || 0 }} 个字段
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTable"
|
||||
class="mt-4 rounded-lg bg-[hsl(var(--primary)/0.08)] p-3 text-sm text-[hsl(var(--foreground))]"
|
||||
>
|
||||
<Icon icon="lucide:info" :size="14" class="mr-1 inline" />
|
||||
已选择表:<strong>{{ selectedTable }}</strong>
|
||||
<span v-if="tableStructure">
|
||||
,共 {{ tableStructure.field?.length || 0 }} 个字段
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-if="tableStructure" class="content-area mb-24 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div
|
||||
v-if="tableStructure"
|
||||
class="content-area mb-24 grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||||
>
|
||||
<Card title="基础信息" class="shadow-sm">
|
||||
<Form />
|
||||
</Card>
|
||||
|
||||
<Card title="字段预览" class="shadow-sm">
|
||||
<div v-if="structureLoading" class="flex h-64 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary mb-2"></i>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">加载表结构中...</p>
|
||||
<div
|
||||
v-if="structureLoading"
|
||||
class="flex h-64 items-center justify-center"
|
||||
>
|
||||
<div class="text-center text-[hsl(var(--muted-foreground))]">
|
||||
<Icon icon="lucide:loader-circle" :size="28" class="mb-2 animate-spin text-primary" />
|
||||
<p class="text-sm">加载表结构中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="max-h-[500px] overflow-auto">
|
||||
@@ -278,70 +236,50 @@ onMounted(() => {
|
||||
{ title: '类型', dataIndex: 'type', key: 'type' },
|
||||
{ title: '长度', dataIndex: 'type_length', key: 'type_length' },
|
||||
{ title: '注释', dataIndex: 'comment', key: 'comment' },
|
||||
{ title: '必填', dataIndex: 'not_null', key: 'not_null', customRender: (text: any) => text ? '是' : '否' },
|
||||
]"
|
||||
:data-source="tableStructure.field"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
row-key="name"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2 text-sm text-gray-500">
|
||||
<i class="fa-solid fa-list mr-1"></i>
|
||||
<span>共 {{ tableStructure.field?.length || 0 }} 个字段</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 固定底部操作栏 -->
|
||||
<div class="fixed-bottom-bar">
|
||||
<div class="fixed-bottom-bar-wrapper">
|
||||
<div class="fixed-bottom-bar-content">
|
||||
<!-- 左侧状态信息 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full transition-all"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full"
|
||||
:class="
|
||||
tableStructure
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600'
|
||||
: 'bg-[hsl(var(--muted))] text-[hsl(var(--muted-foreground))]'
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="tableStructure ? 'fa-solid fa-check-circle' : 'fa-solid fa-circle-info'"
|
||||
></i>
|
||||
<Icon
|
||||
:icon="tableStructure ? 'lucide:check-circle-2' : 'lucide:info'"
|
||||
:size="18"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm font-medium"
|
||||
:class="
|
||||
tableStructure
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
"
|
||||
>
|
||||
<div class="text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
<span v-if="tableStructure">
|
||||
已加载 <span class="text-primary font-semibold">{{ tableStructure.field?.length || 0 }}</span> 个字段
|
||||
已加载 {{ tableStructure.field?.length || 0 }} 个字段
|
||||
</span>
|
||||
<span v-else>请先选择数据库表</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ tableStructure ? '数据已就绪,可以生成代码' : '等待选择表...' }}
|
||||
<span v-else>请点击上方卡片选择数据表</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="action-btn-primary"
|
||||
:disabled="!tableStructure"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<i class="fa-solid fa-code mr-2"></i>生成代码
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!tableStructure"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<Icon icon="lucide:code-xml" :size="14" class="mr-2" />生成代码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,11 +292,73 @@ onMounted(() => {
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
min-height: 400px;
|
||||
.table-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.45);
|
||||
box-shadow: 0 8px 20px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.table-card.is-active {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.table-card__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
color: hsl(var(--primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-card__name {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.table-card__comment,
|
||||
.table-card__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.table-card__check {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* 固定底部操作栏 */
|
||||
.fixed-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@@ -372,21 +372,14 @@ onMounted(() => {
|
||||
.fixed-bottom-bar-wrapper {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: hsl(var(--card, var(--background)) / 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 -8px 32px hsl(var(--foreground) / 0.08);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.dark .fixed-bottom-bar-wrapper {
|
||||
background: rgba(31, 41, 55, 0.98);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fixed-bottom-bar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -395,20 +388,6 @@ onMounted(() => {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.action-btn-primary {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
padding: 0 24px;
|
||||
height: 44px;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.4);
|
||||
}
|
||||
|
||||
@keyframes fade-slide-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button, Card, Checkbox, Input, message, Select, Tabs, Upload } from 'an
|
||||
import { JsonViewer } from '@vben/common-ui';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { codeGenerationForm } from '#/views/code-generation/config/form';
|
||||
|
||||
import type { CodeGenData, FieldType } from '../utils/data-transformer';
|
||||
@@ -19,6 +20,12 @@ import {
|
||||
saveDraft,
|
||||
type GenerationMode,
|
||||
} from '../utils/draft-manager';
|
||||
import {
|
||||
CODE_GEN_JSON_BATCH_EXAMPLE,
|
||||
CODE_GEN_JSON_EXAMPLE,
|
||||
copyTextToClipboard,
|
||||
downloadJsonFile,
|
||||
} from '../utils/json-example';
|
||||
|
||||
const mode: GenerationMode = 'json';
|
||||
|
||||
@@ -330,6 +337,30 @@ const handleDeleteDraft = (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 复制标准 JSON 格式到剪贴板,方便粘贴改写
|
||||
*/
|
||||
const handleCopyJsonFormat = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(JSON.stringify(CODE_GEN_JSON_EXAMPLE, null, 2));
|
||||
message.success('已复制 JSON 格式示例');
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载单模块 / 批量示例 JSON 文件
|
||||
*/
|
||||
const handleDownloadExample = (type: 'single' | 'batch' = 'single') => {
|
||||
if (type === 'batch') {
|
||||
downloadJsonFile(CODE_GEN_JSON_BATCH_EXAMPLE, 'code-gen-batch-example.json');
|
||||
} else {
|
||||
downloadJsonFile(CODE_GEN_JSON_EXAMPLE, 'code-gen-example.json');
|
||||
}
|
||||
message.success('示例文件已开始下载');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadDraftList();
|
||||
});
|
||||
@@ -384,7 +415,7 @@ onMounted(() => {
|
||||
size="small"
|
||||
@click.stop="handleDeleteDraft(draft.id)"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<Icon icon="lucide:trash-2" :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</Select.Option>
|
||||
@@ -395,6 +426,25 @@ onMounted(() => {
|
||||
|
||||
<Tabs v-model:activeKey="activeTab">
|
||||
<Tabs.TabPane key="text" tab="文本输入">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||
粘贴 JSON,或先复制格式 / 下载示例再改
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="link" size="small" @click="handleCopyJsonFormat">
|
||||
<Icon icon="lucide:link" :size="14" class="mr-1" />
|
||||
复制 JSON 格式
|
||||
</Button>
|
||||
<Button type="link" size="small" @click="handleDownloadExample('single')">
|
||||
<Icon icon="lucide:download" :size="14" class="mr-1" />
|
||||
下载单模块示例
|
||||
</Button>
|
||||
<Button type="link" size="small" @click="handleDownloadExample('batch')">
|
||||
<Icon icon="lucide:download" :size="14" class="mr-1" />
|
||||
下载批量示例
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<Input.TextArea
|
||||
v-model:value="jsonText"
|
||||
@@ -404,19 +454,25 @@ onMounted(() => {
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<div v-if="jsonError" class="mt-2 text-red-500 text-sm">
|
||||
<i class="fa-solid fa-circle-exclamation mr-1"></i>{{ jsonError }}
|
||||
<Icon icon="lucide:circle-alert" :size="14" class="mr-1" />{{ jsonError }}
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<Button @click="formatJson">
|
||||
<i class="fa-solid fa-code mr-2"></i>格式化
|
||||
<Icon icon="lucide:braces" :size="14" class="mr-2" />格式化
|
||||
</Button>
|
||||
<Button @click="clearJson">
|
||||
<i class="fa-solid fa-trash mr-2"></i>清空
|
||||
<Icon icon="lucide:trash-2" :size="14" class="mr-2" />清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="file" tab="文件上传">
|
||||
<div class="mb-3 flex flex-wrap justify-end gap-2">
|
||||
<Button type="link" size="small" @click="handleDownloadExample('single')">
|
||||
<Icon icon="lucide:download" :size="14" class="mr-1" />
|
||||
下载示例文件
|
||||
</Button>
|
||||
</div>
|
||||
<div class="upload-container">
|
||||
<Upload.Dragger
|
||||
:file-list="fileList"
|
||||
@@ -428,7 +484,7 @@ onMounted(() => {
|
||||
>
|
||||
<div class="upload-content">
|
||||
<div class="upload-icon">
|
||||
<i class="fa-solid fa-cloud-arrow-up text-4xl text-primary"></i>
|
||||
<Icon icon="lucide:cloud-upload" :size="40" class="text-primary" />
|
||||
</div>
|
||||
<p class="upload-text">
|
||||
点击或拖拽文件到此区域上传
|
||||
@@ -439,7 +495,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
<div v-if="uploadError" class="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||
<i class="fa-solid fa-circle-exclamation mr-2"></i>{{ uploadError }}
|
||||
<Icon icon="lucide:circle-alert" :size="14" class="mr-2" />{{ uploadError }}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
@@ -523,7 +579,7 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2 text-sm text-gray-500">
|
||||
<i class="fa-solid fa-list mr-1"></i>
|
||||
<Icon icon="lucide:list" :size="14" class="mr-1" />
|
||||
<span>共 {{ (parsedData as CodeGenData).field?.length || 0 }} 个字段</span>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -543,9 +599,10 @@ onMounted(() => {
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600'
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="parsedData ? 'fa-solid fa-check-circle' : 'fa-solid fa-circle-info'"
|
||||
></i>
|
||||
<Icon
|
||||
:icon="parsedData ? 'lucide:check-circle-2' : 'lucide:info'"
|
||||
:size="18"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
@@ -592,7 +649,7 @@ onMounted(() => {
|
||||
class="action-btn-secondary"
|
||||
@click="clearJson"
|
||||
>
|
||||
<i class="fa-solid fa-trash mr-2"></i>清空
|
||||
<Icon icon="lucide:trash-2" :size="14" class="mr-2" />清空
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
@@ -600,7 +657,7 @@ onMounted(() => {
|
||||
:disabled="!parsedData"
|
||||
@click="handleSaveDraft"
|
||||
>
|
||||
<i class="fa-solid fa-save mr-2"></i>保存草稿
|
||||
<Icon icon="lucide:save" :size="14" class="mr-2" />保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -609,7 +666,7 @@ onMounted(() => {
|
||||
:disabled="!parsedData || (isBatchMode && selectedModules.length === 0)"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<i class="fa-solid fa-code mr-2"></i>
|
||||
<Icon icon="lucide:code-xml" :size="14" class="mr-2" />
|
||||
{{ isBatchMode ? `批量生成 (${selectedModules.length})` : '生成代码' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { Button, Card, message, Select, Steps } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { codeGenerationForm } from '#/views/code-generation/config/form';
|
||||
|
||||
import CodeGenerationFiledTable from './fileds.vue';
|
||||
@@ -206,7 +207,7 @@ onMounted(() => {
|
||||
size="small"
|
||||
@click.stop="handleDeleteDraft(draft.id)"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<Icon icon="lucide:trash-2" :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</Select.Option>
|
||||
@@ -237,7 +238,7 @@ onMounted(() => {
|
||||
<!-- 左侧状态信息 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
<Icon icon="lucide:list-checks" :size="16" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
@@ -261,10 +262,10 @@ onMounted(() => {
|
||||
class="action-btn-secondary"
|
||||
@click="handlePrev"
|
||||
>
|
||||
<i class="fa-solid fa-arrow-left mr-2"></i>上一步
|
||||
<Icon icon="lucide:arrow-left" :size="14" class="mr-2" />上一步
|
||||
</Button>
|
||||
<Button size="large" class="action-btn-secondary" @click="handleSaveDraft">
|
||||
<i class="fa-solid fa-save mr-2"></i>保存草稿
|
||||
<Icon icon="lucide:save" :size="14" class="mr-2" />保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
v-if="stepsCurrent < stepsItems.length - 1"
|
||||
@@ -274,7 +275,7 @@ onMounted(() => {
|
||||
@click="handleNext"
|
||||
>
|
||||
下一步
|
||||
<i class="fa-solid fa-arrow-right ml-2"></i>
|
||||
<Icon icon="lucide:arrow-right" :size="14" class="ml-2" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="stepsCurrent === stepsItems.length - 1"
|
||||
@@ -283,7 +284,7 @@ onMounted(() => {
|
||||
class="action-btn-primary"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<i class="fa-solid fa-code mr-2"></i>生成代码
|
||||
<Icon icon="lucide:code-xml" :size="14" class="mr-2" />生成代码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ const modes: ModeOption[] = [
|
||||
{
|
||||
mode: 'ai',
|
||||
title: 'AI提示词',
|
||||
description: '使用AI智能生成代码结构(开发中)',
|
||||
description: '用自然语言描述功能,AI 自动填写代码结构',
|
||||
iconColor: '#10b981',
|
||||
borderGradient:
|
||||
'linear-gradient(90deg, #10b981, #059669, #34d399, #059669, #10b981)',
|
||||
|
||||
@@ -37,11 +37,12 @@ export const codeGenerationForm: VbenFormProps = {
|
||||
{
|
||||
component: 'IconPicker',
|
||||
componentProps: {
|
||||
placeholder: '请输入菜单图标',
|
||||
placeholder: '请选择菜单图标',
|
||||
prefix: 'lucide',
|
||||
},
|
||||
fieldName: 'icon',
|
||||
label: '菜单图标',
|
||||
defaultValue: '',
|
||||
defaultValue: 'lucide:box',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button, Drawer, Empty, List, message, Modal, Tag } from 'ant-design-vue
|
||||
import { JsonViewer } from '@vben/common-ui';
|
||||
|
||||
import { downloadByData } from '#/util/tool';
|
||||
import { Icon } from '#/components/icon';
|
||||
import {
|
||||
batchGenerationApi,
|
||||
codeGenerationDownloadApi,
|
||||
@@ -190,7 +191,7 @@ const handleViewJson = (item: GenerationHistory) => {
|
||||
type="primary"
|
||||
@click="handleOpenHistory"
|
||||
>
|
||||
<i class="fa-solid fa-clock-rotate-left mr-2"></i>生成历史
|
||||
<Icon icon="lucide:history" :size="14" class="mr-2" />生成历史
|
||||
</Button>
|
||||
</template>
|
||||
<Transition name="fade-slide" mode="out-in">
|
||||
@@ -260,7 +261,7 @@ const handleViewJson = (item: GenerationHistory) => {
|
||||
>
|
||||
<div v-if="historyLoading" class="flex h-64 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary mb-2"></i>
|
||||
<Icon icon="lucide:loader-circle" :size="28" class="mb-2 animate-spin text-primary" />
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,7 +332,7 @@ const handleViewJson = (item: GenerationHistory) => {
|
||||
class="history-btn-link"
|
||||
@click="handleViewJson(item)"
|
||||
>
|
||||
<i class="fa-solid fa-code mr-1"></i>
|
||||
<Icon icon="lucide:braces" :size="14" class="mr-1" />
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
@@ -340,7 +341,7 @@ const handleViewJson = (item: GenerationHistory) => {
|
||||
class="history-btn-primary"
|
||||
@click="handleDownloadHistory(item)"
|
||||
>
|
||||
<i class="fa-solid fa-download mr-1"></i>
|
||||
<Icon icon="lucide:download" :size="14" class="mr-1" />
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -195,8 +195,17 @@ export function transformAiToStandard(aiData: any): CodeGenData {
|
||||
};
|
||||
}
|
||||
|
||||
/** 系统自动维护字段,禁止出现在业务 field 里 */
|
||||
const RESERVED_FIELD_NAMES = new Set([
|
||||
'id',
|
||||
'status',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 数据验证
|
||||
* 数据验证(导入前兜底,避免脏 class_name / 字段名进建表 SQL)
|
||||
*/
|
||||
export function validateData(data: CodeGenData): {
|
||||
valid: boolean;
|
||||
@@ -205,6 +214,12 @@ export function validateData(data: CodeGenData): {
|
||||
if (!data.class_name || !data.class_name.trim()) {
|
||||
return { valid: false, message: '类名不能为空' };
|
||||
}
|
||||
if (!/^[A-Z][A-Za-z0-9]{0,63}$/.test(data.class_name.trim())) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '类名须为英文 PascalCase(如 VipMember),不能含中文或下划线',
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.class_comment || !data.class_comment.trim()) {
|
||||
return { valid: false, message: '中文名称不能为空' };
|
||||
@@ -218,11 +233,31 @@ export function validateData(data: CodeGenData): {
|
||||
return { valid: false, message: '至少需要一个字段' };
|
||||
}
|
||||
|
||||
// 验证字段
|
||||
const seen = new Set<string>();
|
||||
for (const field of data.field) {
|
||||
if (!field.name || !field.name.trim()) {
|
||||
const name = (field.name || '').trim();
|
||||
if (!name) {
|
||||
return { valid: false, message: '字段名不能为空' };
|
||||
}
|
||||
if (name.length > 64 || /[\s,。::|()()]/.test(name)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `字段名非法「${name}」,须为短小写 snake_case`,
|
||||
};
|
||||
}
|
||||
if (!/^[a-z][a-z0-9_]{0,63}$/.test(name)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `字段名非法「${name}」,须为小写 snake_case(如 user_name)`,
|
||||
};
|
||||
}
|
||||
if (RESERVED_FIELD_NAMES.has(name)) {
|
||||
return { valid: false, message: `字段「${name}」为系统保留字段` };
|
||||
}
|
||||
if (seen.has(name)) {
|
||||
return { valid: false, message: `字段名重复「${name}」` };
|
||||
}
|
||||
seen.add(name);
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 代码生成 JSON 示例(单模块 + 批量各一份说明用)
|
||||
* 用于「复制 JSON 格式」和「下载示例文件」
|
||||
*/
|
||||
export const CODE_GEN_JSON_EXAMPLE = {
|
||||
class_name: 'VipMember',
|
||||
class_comment: 'VIP会员',
|
||||
icon: 'lucide:crown',
|
||||
sort: 9999,
|
||||
pid: 0,
|
||||
field: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'varchar',
|
||||
type_length: '64',
|
||||
default: '',
|
||||
comment: '标题',
|
||||
not_null: true,
|
||||
formShow: true,
|
||||
tableShow: true,
|
||||
formType: 'VbenInput',
|
||||
search: true,
|
||||
searchValue: 'like',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
type: 'tinyint',
|
||||
type_length: '1',
|
||||
default: '0',
|
||||
comment: '等级',
|
||||
not_null: true,
|
||||
formShow: true,
|
||||
tableShow: true,
|
||||
formType: 'InputNumber',
|
||||
search: true,
|
||||
searchValue: '=',
|
||||
},
|
||||
{
|
||||
name: 'remark',
|
||||
type: 'varchar',
|
||||
type_length: '255',
|
||||
default: '',
|
||||
comment: '备注',
|
||||
not_null: false,
|
||||
formShow: true,
|
||||
tableShow: false,
|
||||
formType: 'Textarea',
|
||||
search: false,
|
||||
searchValue: '=',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 批量示例:外层为数组 */
|
||||
export const CODE_GEN_JSON_BATCH_EXAMPLE = [
|
||||
CODE_GEN_JSON_EXAMPLE,
|
||||
{
|
||||
...CODE_GEN_JSON_EXAMPLE,
|
||||
class_name: 'VipBenefit',
|
||||
class_comment: 'VIP权益',
|
||||
icon: 'lucide:gift',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
*/
|
||||
export async function copyTextToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发浏览器下载 JSON 文件
|
||||
*/
|
||||
export function downloadJsonFile(data: unknown, filename: string) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
70
apps/web-antd/src/views/system/config/api/index.ts
Normal file
70
apps/web-antd/src/views/system/config/api/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'ai-config/';
|
||||
|
||||
/**
|
||||
* 获取 AI 管理页选项(平台/模型/密钥 + 当前激活组合)
|
||||
*/
|
||||
export async function getAiRuntimeOptions() {
|
||||
return requestClient.get<any>(`${prefix}runtime-options`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存默认 AI 平台/模型/密钥
|
||||
*/
|
||||
export async function saveAiRuntime(data: {
|
||||
provider: string;
|
||||
model: string;
|
||||
api_key_id: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}save-runtime`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥列表
|
||||
*/
|
||||
export async function getAiKeyList(data: Record<string, any> = {}) {
|
||||
return requestClient.get<any>(`${prefix}key-list`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台下拉
|
||||
*/
|
||||
export async function getAiPlatformOption() {
|
||||
return requestClient.get<any>(`${prefix}platform-option`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增密钥
|
||||
*/
|
||||
export async function createAiKey(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create-key`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新密钥
|
||||
*/
|
||||
export async function updateAiKey(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update-key`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除密钥
|
||||
*/
|
||||
export async function deleteAiKey(ids: number[]) {
|
||||
return requestClient.post<any>(`${prefix}delete-key`, { ids });
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 生成历史列表
|
||||
*/
|
||||
export async function getAiGenerationList(data: Record<string, any> = {}) {
|
||||
return requestClient.get<any>(`${prefix}generation-list`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 生成历史详情
|
||||
*/
|
||||
export async function getAiGenerationDetail(id: number) {
|
||||
return requestClient.get<any>(`${prefix}generation-detail`, { params: { id } });
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 生成记录:展示代码生成 / 需求概览等 AI 调用流水
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import { getAiGenerationDetail, getAiGenerationList } from '../api';
|
||||
|
||||
interface GenerationItem {
|
||||
id: number;
|
||||
name: string;
|
||||
scene: string;
|
||||
scene_text?: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
status: number;
|
||||
status_text?: string;
|
||||
total_tokens: number;
|
||||
duration_ms: number;
|
||||
error_msg: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<GenerationItem[]>([]);
|
||||
const detailLoading = ref(false);
|
||||
const activeDetail = ref<any>(null);
|
||||
|
||||
/**
|
||||
* 解析 JSON 字段(驱动可能返回对象或字符串)
|
||||
*/
|
||||
function parseJsonField(value: unknown) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'object') return value as Record<string, any>;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 需求文案类结果:展示纯文本便于复制 */
|
||||
const requirementView = computed(() => {
|
||||
if (!activeDetail.value || activeDetail.value.scene !== 'requirement_prompt') {
|
||||
return null;
|
||||
}
|
||||
const result = parseJsonField(activeDetail.value.result_json);
|
||||
if (!result) {
|
||||
// 兼容极早期只存 raw 的情况
|
||||
const raw = activeDetail.value.raw_response;
|
||||
return raw ? { text: String(raw) } : null;
|
||||
}
|
||||
const text = String(result.text || result.prompt || result.overview || '');
|
||||
return text ? { text } : null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载生成记录
|
||||
*/
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiGenerationList({ pageSize: 50 });
|
||||
list.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载生成记录失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id: number) {
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
activeDetail.value = await getAiGenerationDetail(id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status === 1) return 'success';
|
||||
if (status === 2) return 'error';
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板(系统配置页查看历史时方便拿走提示词)
|
||||
*/
|
||||
async function copyText(text: string, label: string) {
|
||||
if (!text) {
|
||||
message.warning('内容为空');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success(`已复制${label}`);
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Spin :spinning="loading">
|
||||
<div class="gen-log">
|
||||
<div class="gen-log__toolbar">
|
||||
<p>
|
||||
记录每次 AI 调用(代码生成 / 需求概览)的供应商、模型、耗时与结果。
|
||||
</p>
|
||||
<Button @click="loadList">
|
||||
<Icon icon="lucide:refresh-cw" :size="14" class="mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="list.length" class="gen-log__list">
|
||||
<button
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="gen-item"
|
||||
:class="{ 'is-active': activeDetail?.id === item.id }"
|
||||
@click="openDetail(item.id)"
|
||||
>
|
||||
<div class="gen-item__main">
|
||||
<div class="gen-item__title">
|
||||
<span>{{ item.name || '未命名' }}</span>
|
||||
<Tag :color="statusColor(item.status)">
|
||||
{{ item.status_text || '未知' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="gen-item__meta">
|
||||
<span>{{ item.scene_text || item.scene }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ item.provider }} / {{ item.model }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ item.total_tokens || 0 }} tokens</span>
|
||||
<span>·</span>
|
||||
<span>{{ item.duration_ms || 0 }} ms</span>
|
||||
</div>
|
||||
<div class="gen-item__time">{{ item.created_at }}</div>
|
||||
</div>
|
||||
<Icon icon="lucide:chevron-right" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="gen-log__empty">暂无生成记录</div>
|
||||
|
||||
<div v-if="activeDetail" class="gen-log__detail">
|
||||
<Spin :spinning="detailLoading">
|
||||
<div class="gen-log__detail-head">
|
||||
<h4>记录详情 #{{ activeDetail.id }}</h4>
|
||||
<Button type="text" @click="activeDetail = null">关闭</Button>
|
||||
</div>
|
||||
<div v-if="activeDetail.error_msg" class="gen-log__error">
|
||||
{{ activeDetail.error_msg }}
|
||||
</div>
|
||||
|
||||
<div v-if="requirementView" class="gen-log__req">
|
||||
<div class="gen-log__req-block">
|
||||
<div class="gen-log__req-head">
|
||||
<span>提示词文案</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="copyText(requirementView.text, '文案')"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div class="gen-log__req-body">{{ requirementView.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="gen-log__json">{{
|
||||
JSON.stringify(
|
||||
{
|
||||
input:
|
||||
parseJsonField(activeDetail.input_snapshot) ??
|
||||
activeDetail.input_snapshot,
|
||||
result:
|
||||
parseJsonField(activeDetail.result_json) ??
|
||||
activeDetail.result_json,
|
||||
usage:
|
||||
parseJsonField(activeDetail.usage_json) ??
|
||||
activeDetail.usage_json,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}}</pre>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gen-log {
|
||||
max-width: 980px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.gen-log__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gen-log__toolbar p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.gen-log__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gen-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.gen-item:hover,
|
||||
.gen-item.is-active {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
box-shadow: 0 10px 24px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.gen-item__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.gen-item__meta,
|
||||
.gen-item__time {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.gen-log__empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.gen-log__detail {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
}
|
||||
|
||||
.gen-log__detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.gen-log__detail-head h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gen-log__error {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: hsl(0 72% 55% / 0.12);
|
||||
color: hsl(0 72% 45%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gen-log__req {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gen-log__req-block {
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gen-log__req-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.gen-log__req-body {
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.gen-log__json {
|
||||
margin: 0;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--background));
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
</style>
|
||||
715
apps/web-antd/src/views/system/config/components/AiKeyManage.vue
Normal file
715
apps/web-antd/src/views/system/config/components/AiKeyManage.vue
Normal file
@@ -0,0 +1,715 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI密钥:C 端风格密钥卡片(与模型选择页视觉语言一致)
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Input, Select, Spin, Switch, message } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import {
|
||||
createAiKey,
|
||||
deleteAiKey,
|
||||
getAiKeyList,
|
||||
getAiPlatformOption,
|
||||
updateAiKey,
|
||||
} from '../api';
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [];
|
||||
}>();
|
||||
|
||||
interface PlatformOption {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
interface KeyCard {
|
||||
id: number;
|
||||
platform_id: number;
|
||||
name: string;
|
||||
remark: string;
|
||||
is_default: number;
|
||||
status: number;
|
||||
has_key: boolean;
|
||||
platform?: PlatformOption | null;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<KeyCard[]>([]);
|
||||
const platforms = ref<PlatformOption[]>([]);
|
||||
const editingId = ref<number | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
platform_id: undefined as number | undefined,
|
||||
name: '',
|
||||
api_key: '',
|
||||
remark: '',
|
||||
is_default: false,
|
||||
status: true,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
title: 'API 密钥',
|
||||
class: 'w-[520px]',
|
||||
onConfirm: async () => {
|
||||
if (!form.platform_id) {
|
||||
message.warning('请选择平台');
|
||||
return;
|
||||
}
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请填写密钥别名');
|
||||
return;
|
||||
}
|
||||
if (!editingId.value && !form.api_key.trim()) {
|
||||
message.warning('请填写 API Key');
|
||||
return;
|
||||
}
|
||||
formModalApi.lock();
|
||||
try {
|
||||
const payload = {
|
||||
platform_id: form.platform_id,
|
||||
name: form.name.trim(),
|
||||
remark: form.remark.trim(),
|
||||
is_default: form.is_default ? 1 : 0,
|
||||
status: form.status ? 1 : 0,
|
||||
api_key: form.api_key.trim(),
|
||||
};
|
||||
if (editingId.value) {
|
||||
await updateAiKey({ id: editingId.value, ...payload });
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createAiKey(payload);
|
||||
message.success('创建成功');
|
||||
}
|
||||
formModalApi.close();
|
||||
await loadList();
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
formModalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载密钥卡片列表
|
||||
*/
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [keyRes, platRes] = await Promise.all([
|
||||
getAiKeyList({ pageSize: 100 }),
|
||||
getAiPlatformOption(),
|
||||
]);
|
||||
list.value = keyRes?.items ?? [];
|
||||
platforms.value = platRes ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载密钥失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.platform_id = undefined;
|
||||
form.name = '';
|
||||
form.api_key = '';
|
||||
form.remark = '';
|
||||
form.is_default = false;
|
||||
form.status = true;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
formModalApi.setState({ title: '新增 API 密钥' });
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
function openEdit(row: KeyCard) {
|
||||
editingId.value = row.id;
|
||||
form.platform_id = row.platform_id;
|
||||
form.name = row.name;
|
||||
form.api_key = '';
|
||||
form.remark = row.remark || '';
|
||||
form.is_default = row.is_default === 1;
|
||||
form.status = row.status === 1;
|
||||
formModalApi.setState({ title: '编辑 API 密钥' });
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(row: KeyCard) {
|
||||
try {
|
||||
await deleteAiKey([row.id]);
|
||||
message.success('已删除');
|
||||
await loadList();
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Spin :spinning="loading">
|
||||
<div class="ai-keys">
|
||||
<div class="ai-keys__hero">
|
||||
<div class="ai-keys__hero-copy">
|
||||
<div class="ai-keys__eyebrow">
|
||||
<Icon icon="lucide:shield-check" :size="14" />
|
||||
安全存储
|
||||
</div>
|
||||
<h3>管理各平台 API Key</h3>
|
||||
<p>密钥入库前 AES 加密,卡片只展示别名,不回显明文。</p>
|
||||
</div>
|
||||
<button type="button" class="ai-keys__add" @click="openCreate">
|
||||
<Icon icon="lucide:plus" :size="16" />
|
||||
新增密钥
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="list.length" class="ai-keys__grid">
|
||||
<article
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="key-card"
|
||||
:class="{
|
||||
'is-default': item.is_default === 1,
|
||||
'is-off': item.status !== 1,
|
||||
}"
|
||||
>
|
||||
<div class="key-card__glow"></div>
|
||||
<div class="key-card__head">
|
||||
<div class="key-card__brand">
|
||||
<div class="key-card__avatar">
|
||||
<img
|
||||
v-if="item.platform?.logo"
|
||||
:src="item.platform.logo"
|
||||
:alt="item.platform?.name"
|
||||
/>
|
||||
<Icon v-else icon="lucide:cpu" :size="22" />
|
||||
</div>
|
||||
<div class="key-card__title">
|
||||
<div class="key-card__name-row">
|
||||
<h4>{{ item.name }}</h4>
|
||||
<span v-if="item.is_default === 1" class="key-card__star">
|
||||
<Icon icon="lucide:sparkles" :size="12" />
|
||||
默认
|
||||
</span>
|
||||
</div>
|
||||
<div class="key-card__meta">
|
||||
<span>{{ item.platform?.name || '未知平台' }}</span>
|
||||
<span class="dot">·</span>
|
||||
<code>ID {{ item.id }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="key-card__ops">
|
||||
<button
|
||||
type="button"
|
||||
class="key-card__op"
|
||||
title="编辑"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<Icon icon="lucide:pencil" :size="15" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="key-card__op is-danger"
|
||||
title="删除"
|
||||
@click="handleDelete(item)"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-card__secret">
|
||||
<Icon icon="lucide:lock-keyhole" :size="14" />
|
||||
<span>{{ item.has_key ? '••••••••••••••••••••' : '尚未配置密钥' }}</span>
|
||||
</div>
|
||||
|
||||
<p class="key-card__remark">
|
||||
{{ item.remark || '暂无备注' }}
|
||||
</p>
|
||||
|
||||
<div class="key-card__foot">
|
||||
<span
|
||||
class="status-chip"
|
||||
:class="item.status === 1 ? 'is-on' : 'is-off'"
|
||||
>
|
||||
<Icon
|
||||
:icon="item.status === 1 ? 'lucide:check' : 'lucide:ban'"
|
||||
:size="12"
|
||||
/>
|
||||
{{ item.status === 1 ? '启用' : '禁用' }}
|
||||
</span>
|
||||
<span
|
||||
class="status-chip"
|
||||
:class="item.has_key ? 'is-on' : 'is-warn'"
|
||||
>
|
||||
<Icon
|
||||
:icon="item.has_key ? 'lucide:check' : 'lucide:circle-alert'"
|
||||
:size="12"
|
||||
/>
|
||||
{{ item.has_key ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="ai-keys__empty">
|
||||
<div class="ai-keys__empty-icon">
|
||||
<Icon icon="lucide:key-round" :size="28" />
|
||||
</div>
|
||||
<h4>还没有密钥</h4>
|
||||
<p>添加后即可在「模型选择」中点选使用</p>
|
||||
<button type="button" class="ai-keys__add" @click="openCreate">
|
||||
<Icon icon="lucide:plus" :size="16" />
|
||||
立即添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormModal>
|
||||
<div class="key-form">
|
||||
<label>所属平台</label>
|
||||
<Select
|
||||
v-model:value="form.platform_id"
|
||||
placeholder="请选择平台"
|
||||
style="width: 100%"
|
||||
:options="
|
||||
platforms.map((p) => ({
|
||||
label: `${p.name}(${p.code})`,
|
||||
value: p.id,
|
||||
}))
|
||||
"
|
||||
/>
|
||||
<label>密钥别名</label>
|
||||
<Input v-model:value="form.name" placeholder="例如 DeepSeek-生产" />
|
||||
<label>
|
||||
API Key
|
||||
<span v-if="editingId" class="muted">(留空表示不修改)</span>
|
||||
</label>
|
||||
<Input.Password
|
||||
v-model:value="form.api_key"
|
||||
placeholder="请输入 API Key"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<label>备注</label>
|
||||
<Input.TextArea
|
||||
v-model:value="form.remark"
|
||||
:rows="3"
|
||||
placeholder="可选"
|
||||
/>
|
||||
<div class="key-form__switches">
|
||||
<div>
|
||||
<span>设为默认</span>
|
||||
<Switch v-model:checked="form.is_default" />
|
||||
</div>
|
||||
<div>
|
||||
<span>启用</span>
|
||||
<Switch v-model:checked="form.status" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</div>
|
||||
</Spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-keys {
|
||||
max-width: 980px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.ai-keys__hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid hsl(var(--primary) / 0.18);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.14),
|
||||
hsl(var(--primary) / 0.03) 42%,
|
||||
transparent 70%
|
||||
),
|
||||
hsl(var(--card, var(--background)));
|
||||
}
|
||||
|
||||
.ai-keys__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ai-keys__hero h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ai-keys__hero p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-keys__add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary));
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 24px hsl(var(--primary) / 0.28);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-keys__add:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 14px 28px hsl(var(--primary) / 0.35);
|
||||
}
|
||||
|
||||
.ai-keys__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.key-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 220px;
|
||||
padding: 20px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 12px 32px hsl(var(--foreground) / 0.05);
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
border-color 0.22s ease,
|
||||
box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
.key-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 18px 40px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.key-card.is-default {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background:
|
||||
linear-gradient(
|
||||
165deg,
|
||||
hsl(var(--primary) / 0.1),
|
||||
hsl(var(--card, var(--background))) 46%
|
||||
);
|
||||
}
|
||||
|
||||
.key-card.is-off {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.key-card__glow {
|
||||
position: absolute;
|
||||
top: -40%;
|
||||
right: -20%;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
hsl(var(--primary) / 0.16),
|
||||
transparent 68%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.key-card__head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.key-card__brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.key-card__avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
color: hsl(var(--primary));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px hsl(var(--border)),
|
||||
0 8px 18px hsl(var(--foreground) / 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key-card__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.key-card__title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.key-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.key-card__name-row h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.key-card__star {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--warning, 38 92% 50%));
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.14);
|
||||
}
|
||||
|
||||
.key-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.key-card__meta code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.key-card__ops {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key-card__op {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background) / 0.55);
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.key-card__op:hover {
|
||||
color: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.key-card__op.is-danger:hover {
|
||||
color: hsl(0 72% 55%);
|
||||
border-color: hsl(0 72% 55% / 0.4);
|
||||
background: hsl(0 72% 55% / 0.1);
|
||||
}
|
||||
|
||||
.key-card__secret {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
border: 1px dashed hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.key-card__remark {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
min-height: 40px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.key-card__foot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-chip.is-on {
|
||||
color: hsl(142 55% 42%);
|
||||
background: hsl(142 55% 42% / 0.14);
|
||||
}
|
||||
|
||||
.status-chip.is-warn {
|
||||
color: hsl(var(--warning, 38 92% 45%));
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.14);
|
||||
}
|
||||
|
||||
.status-chip.is-off {
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
}
|
||||
|
||||
.ai-keys__empty {
|
||||
padding: 56px 20px;
|
||||
border-radius: 22px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.15);
|
||||
}
|
||||
|
||||
.ai-keys__empty-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.ai-keys__empty h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ai-keys__empty p {
|
||||
margin: 6px 0 18px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.key-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 4px 4px;
|
||||
}
|
||||
|
||||
.key-form label {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.key-form .muted {
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.key-form__switches {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.key-form__switches > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ai-keys__hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ai-keys__add {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI管理:Tab 切换「模型选择」与「API Key管理」
|
||||
* 模型选择负责默认运行时组合;API Key 负责密钥 CRUD(卡片式)
|
||||
*/
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Tabs } from 'ant-design-vue';
|
||||
|
||||
import AiGenerationLog from './AiGenerationLog.vue';
|
||||
import AiKeyManage from './AiKeyManage.vue';
|
||||
import AiRuntimeSelect from './AiRuntimeSelect.vue';
|
||||
|
||||
const TAB_STORAGE_KEY = 'nl_system_config_ai_tab';
|
||||
|
||||
type AiTabKey = 'runtime' | 'apikey' | 'history';
|
||||
|
||||
const activeTab = ref<AiTabKey>(
|
||||
(localStorage.getItem(TAB_STORAGE_KEY) as AiTabKey) || 'runtime',
|
||||
);
|
||||
/** 切换回模型选择时强制刷新,拿到最新密钥卡片 */
|
||||
const runtimeReloadKey = ref(0);
|
||||
|
||||
watch(activeTab, (val, oldVal) => {
|
||||
localStorage.setItem(TAB_STORAGE_KEY, val);
|
||||
if (val === 'runtime' && oldVal === 'apikey') {
|
||||
runtimeReloadKey.value += 1;
|
||||
}
|
||||
});
|
||||
|
||||
function onKeyChanged() {
|
||||
runtimeReloadKey.value += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ai-manage-tabs">
|
||||
<Tabs v-model:active-key="activeTab" class="ai-tabs">
|
||||
<Tabs.TabPane key="runtime" tab="模型选择">
|
||||
<AiRuntimeSelect :key="runtimeReloadKey" />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="apikey" tab="API Key管理">
|
||||
<AiKeyManage @changed="onKeyChanged" />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="history" tab="生成记录">
|
||||
<AiGenerationLog />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-manage-tabs {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.ai-tabs :deep(.ant-tabs-nav) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.ai-tabs :deep(.ant-tabs-tab) {
|
||||
font-weight: 600;
|
||||
padding: 10px 4px;
|
||||
}
|
||||
|
||||
.ai-tabs :deep(.ant-tabs-ink-bar) {
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,494 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 模型选择:卡片点选默认平台 / 密钥 / 模型(C 端风格)
|
||||
* 数据来自 ai-config/runtime-options,保存写入系统配置表
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Spin, message } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import { getAiRuntimeOptions, saveAiRuntime } from '../api';
|
||||
|
||||
interface ModelOption {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: number;
|
||||
}
|
||||
|
||||
interface KeyOption {
|
||||
id: number;
|
||||
name: string;
|
||||
key_name: string;
|
||||
is_default: number;
|
||||
}
|
||||
|
||||
interface PlatformCard {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
default_model: string;
|
||||
models: ModelOption[];
|
||||
keys: KeyOption[];
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const platforms = ref<PlatformCard[]>([]);
|
||||
const provider = ref('');
|
||||
const model = ref('');
|
||||
const apiKeyId = ref(0);
|
||||
|
||||
const currentPlatform = computed(
|
||||
() => platforms.value.find((p) => p.code === provider.value) ?? null,
|
||||
);
|
||||
|
||||
const currentKeys = computed(() => currentPlatform.value?.keys ?? []);
|
||||
const currentModels = computed(() => currentPlatform.value?.models ?? []);
|
||||
|
||||
/**
|
||||
* 拉取平台卡片与当前激活组合
|
||||
*/
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getAiRuntimeOptions();
|
||||
platforms.value = data?.platforms ?? [];
|
||||
provider.value = data?.active?.provider || platforms.value[0]?.code || '';
|
||||
model.value = data?.active?.model || '';
|
||||
apiKeyId.value = Number(data?.active?.api_key_id || 0);
|
||||
syncSelectionForPlatform(provider.value, false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载 AI 配置失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换平台后同步默认模型与密钥,避免跨平台脏数据
|
||||
*/
|
||||
function syncSelectionForPlatform(code: string, reset = true) {
|
||||
const plat = platforms.value.find((p) => p.code === code);
|
||||
if (!plat) return;
|
||||
if (reset || !model.value) {
|
||||
model.value = plat.default_model || plat.models?.[0]?.code || '';
|
||||
}
|
||||
const modelOk = plat.models?.some((m) => m.code === model.value);
|
||||
if (!modelOk) {
|
||||
model.value = plat.default_model || plat.models?.[0]?.code || '';
|
||||
}
|
||||
const keyOk = plat.keys?.some((k) => k.id === apiKeyId.value);
|
||||
if (reset || !keyOk) {
|
||||
const def = plat.keys?.find((k) => k.is_default === 1) || plat.keys?.[0];
|
||||
apiKeyId.value = def ? Number(def.id) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPlatform(code: string) {
|
||||
if (provider.value === code) return;
|
||||
provider.value = code;
|
||||
syncSelectionForPlatform(code, true);
|
||||
}
|
||||
|
||||
function selectKey(id: number) {
|
||||
apiKeyId.value = id;
|
||||
}
|
||||
|
||||
function selectModel(code: string) {
|
||||
model.value = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存默认运行时组合
|
||||
*/
|
||||
async function handleSave() {
|
||||
if (!provider.value) {
|
||||
message.warning('请选择 AI 平台');
|
||||
return;
|
||||
}
|
||||
if (!model.value) {
|
||||
message.warning('请选择模型');
|
||||
return;
|
||||
}
|
||||
if (!currentKeys.value.length) {
|
||||
message.warning('请先在「API Key管理」中录入密钥');
|
||||
return;
|
||||
}
|
||||
if (!apiKeyId.value) {
|
||||
message.warning('请选择 API Key');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveAiRuntime({
|
||||
provider: provider.value,
|
||||
model: model.value,
|
||||
api_key_id: apiKeyId.value,
|
||||
});
|
||||
message.success('AI 配置已保存');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Spin :spinning="loading">
|
||||
<div class="ai-runtime">
|
||||
<div class="ai-tip">
|
||||
<Icon icon="lucide:info" :size="16" />
|
||||
<span>
|
||||
密钥请在「API Key管理」维护;此处点选当前运行时的平台 / 密钥 / 模型组合。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="ai-section">
|
||||
<div class="ai-section__title">
|
||||
<span class="ai-step">1</span>
|
||||
选用 AI 平台
|
||||
</div>
|
||||
<div class="ai-card-grid">
|
||||
<button
|
||||
v-for="item in platforms"
|
||||
:key="item.code"
|
||||
type="button"
|
||||
class="ai-card"
|
||||
:class="{ 'is-active': provider === item.code }"
|
||||
@click="selectPlatform(item.code)"
|
||||
>
|
||||
<span v-if="provider === item.code" class="ai-card__check">
|
||||
<Icon icon="lucide:check" :size="14" />
|
||||
</span>
|
||||
<div class="ai-card__logo">
|
||||
<img v-if="item.logo" :src="item.logo" :alt="item.name" />
|
||||
<Icon v-else icon="lucide:cpu" :size="22" />
|
||||
</div>
|
||||
<div class="ai-card__body">
|
||||
<div class="ai-card__name">{{ item.name }}</div>
|
||||
<div class="ai-card__meta">
|
||||
<code>{{ item.code }}</code>
|
||||
<span v-if="item.default_model">{{ item.default_model }}</span>
|
||||
</div>
|
||||
<div class="ai-card__desc">{{ item.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-section">
|
||||
<div class="ai-section__title">
|
||||
<span class="ai-step">2</span>
|
||||
API Key
|
||||
</div>
|
||||
<div v-if="currentKeys.length" class="ai-card-grid ai-card-grid--sm">
|
||||
<button
|
||||
v-for="item in currentKeys"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="ai-card ai-card--compact"
|
||||
:class="{ 'is-active': apiKeyId === item.id }"
|
||||
@click="selectKey(item.id)"
|
||||
>
|
||||
<span v-if="apiKeyId === item.id" class="ai-card__check">
|
||||
<Icon icon="lucide:check" :size="14" />
|
||||
</span>
|
||||
<div class="ai-card__key-icon">
|
||||
<Icon icon="lucide:key-round" :size="20" />
|
||||
</div>
|
||||
<div class="ai-card__body">
|
||||
<div class="ai-card__name">{{ item.key_name || item.name }}</div>
|
||||
<div class="ai-card__meta">
|
||||
<code>ID {{ item.id }}</code>
|
||||
<span v-if="item.is_default === 1" class="ai-badge">默认</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="ai-empty">
|
||||
当前平台暂无密钥,请先到「API Key管理」添加
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-section">
|
||||
<div class="ai-section__title">
|
||||
<span class="ai-step">3</span>
|
||||
模型
|
||||
</div>
|
||||
<div class="ai-card-grid ai-card-grid--sm">
|
||||
<button
|
||||
v-for="item in currentModels"
|
||||
:key="item.code"
|
||||
type="button"
|
||||
class="ai-card ai-card--compact"
|
||||
:class="{ 'is-active': model === item.code }"
|
||||
@click="selectModel(item.code)"
|
||||
>
|
||||
<span v-if="model === item.code" class="ai-card__check">
|
||||
<Icon icon="lucide:check" :size="14" />
|
||||
</span>
|
||||
<div class="ai-card__body">
|
||||
<div class="ai-card__name-row">
|
||||
<span class="ai-card__name">{{ item.name }}</span>
|
||||
<span v-if="item.is_default === 1" class="ai-badge ai-badge--warn">
|
||||
默认
|
||||
</span>
|
||||
</div>
|
||||
<div class="ai-card__meta">
|
||||
<code>{{ item.code }}</code>
|
||||
</div>
|
||||
<div class="ai-card__desc">{{ item.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ai-actions">
|
||||
<Button type="primary" size="large" :loading="saving" @click="handleSave">
|
||||
<Icon icon="lucide:save" :size="16" class="mr-2" />
|
||||
保存 AI 配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-runtime {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.ai-tip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
border: 1px solid hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.ai-tip :deep(svg) {
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ai-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.ai-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ai-step {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--primary-foreground, 0 0% 100%));
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ai-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ai-card-grid--sm {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.ai-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 10px 30px hsl(var(--foreground) / 0.04);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ai-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 14px 34px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.ai-card.is-active {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--primary) / 0.35),
|
||||
0 16px 40px hsl(var(--primary) / 0.16);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
hsl(var(--primary) / 0.1),
|
||||
hsl(var(--card, var(--background))) 48%
|
||||
);
|
||||
}
|
||||
|
||||
.ai-card--compact {
|
||||
min-height: 108px;
|
||||
}
|
||||
|
||||
.ai-card__check {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--primary));
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px hsl(var(--primary) / 0.35);
|
||||
}
|
||||
|
||||
.ai-card__check :deep(svg) {
|
||||
color: #fff;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.ai-card__logo,
|
||||
.ai-card__key-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ai-card__logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ai-card__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.ai-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-card__name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ai-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-card__meta code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ai-card__desc {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.ai-badge--warn {
|
||||
color: hsl(var(--warning, 38 92% 50%));
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.14);
|
||||
}
|
||||
|
||||
.ai-empty {
|
||||
padding: 28px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
}
|
||||
|
||||
.ai-actions {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.ai-actions :deep(.ant-btn) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ai-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
258
apps/web-antd/src/views/system/config/index.vue
Normal file
258
apps/web-antd/src/views/system/config/index.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 系统配置页:左侧自写菜单;AI管理内用 Tab 切换模型选择 / API Key
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import AiManage from './components/AiManage.vue';
|
||||
|
||||
defineOptions({ name: 'SystemConfig' });
|
||||
|
||||
type ConfigMenuKey = 'ai';
|
||||
|
||||
interface ConfigMenuItem {
|
||||
key: ConfigMenuKey;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** 左侧本地菜单(非后端菜单树);icon 用 iconify,与全站菜单一致 */
|
||||
const menus: ConfigMenuItem[] = [
|
||||
{
|
||||
key: 'ai',
|
||||
title: 'AI管理',
|
||||
desc: '模型选择 / API Key',
|
||||
icon: 'lucide:bot',
|
||||
},
|
||||
];
|
||||
|
||||
const activeKey = ref<ConfigMenuKey>('ai');
|
||||
const activeMenu = computed(
|
||||
() => menus.find((m) => m.key === activeKey.value) ?? menus[0],
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height content-class="!p-0">
|
||||
<div class="sys-config">
|
||||
<aside class="sys-config__aside">
|
||||
<div class="sys-config__brand">
|
||||
<div class="sys-config__brand-icon">
|
||||
<Icon icon="lucide:sliders-horizontal" :size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="sys-config__brand-title">系统配置</div>
|
||||
<div class="sys-config__brand-sub">运行时偏好与能力开关</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sys-config__nav">
|
||||
<button
|
||||
v-for="item in menus"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="sys-config__nav-item"
|
||||
:class="{ 'is-active': activeKey === item.key }"
|
||||
@click="activeKey = item.key"
|
||||
>
|
||||
<span class="sys-config__nav-icon">
|
||||
<Icon :icon="item.icon" :size="18" />
|
||||
</span>
|
||||
<span class="sys-config__nav-text">
|
||||
<span class="sys-config__nav-title">{{ item.title }}</span>
|
||||
<span class="sys-config__nav-desc">{{ item.desc }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="sys-config__main">
|
||||
<header class="sys-config__header">
|
||||
<h2>{{ activeMenu?.title }}</h2>
|
||||
<p>{{ activeMenu?.desc }}</p>
|
||||
</header>
|
||||
<div class="sys-config__body">
|
||||
<AiManage v-if="activeKey === 'ai'" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sys-config {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(
|
||||
1200px 480px at 12% -10%,
|
||||
hsl(var(--primary) / 0.12),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 420px at 100% 0%,
|
||||
hsl(var(--primary) / 0.06),
|
||||
transparent 55%
|
||||
),
|
||||
hsl(var(--background));
|
||||
}
|
||||
|
||||
.sys-config__aside {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)) / 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 20px 14px;
|
||||
}
|
||||
|
||||
.sys-config__brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px 20px;
|
||||
}
|
||||
|
||||
.sys-config__brand-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--primary));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
hsl(var(--primary) / 0.18),
|
||||
hsl(var(--primary) / 0.05)
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.18);
|
||||
}
|
||||
|
||||
.sys-config__brand-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.sys-config__brand-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.sys-config__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sys-config__nav-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.sys-config__nav-item:hover {
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
|
||||
.sys-config__nav-item.is-active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 8px 24px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.sys-config__nav-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sys-config__nav-item.is-active .sys-config__nav-icon {
|
||||
background: hsl(var(--primary) / 0.16);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.sys-config__nav-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sys-config__nav-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sys-config__nav-desc {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sys-config__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px 28px 40px;
|
||||
}
|
||||
|
||||
.sys-config__header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.sys-config__header p {
|
||||
margin: 6px 0 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sys-config__body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sys-config {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sys-config__aside {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.sys-config__nav {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sys-config__nav-item {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 数据库变更记录:筛选 + 列表 + 展开详情
|
||||
* 使用 antd 组件与主题变量,避免原生 input 在暗色下不可读
|
||||
*/
|
||||
import { h, onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Card, message, Table, Tag } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Input,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import { getChangeLogApi } from '../api';
|
||||
|
||||
@@ -19,31 +33,29 @@ const pagination = ref({
|
||||
|
||||
const filters = ref({
|
||||
table_name: '',
|
||||
operation_type: '',
|
||||
operation_type: undefined as string | undefined,
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
});
|
||||
|
||||
// 操作类型选项
|
||||
const operationTypeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '结构变更', value: 'structure_change' },
|
||||
{ label: '数据变更', value: 'data_change' },
|
||||
{ label: 'SQL查询', value: 'sql_query' },
|
||||
];
|
||||
|
||||
// 表列表(从筛选条件获取,实际应该从API获取)
|
||||
const tableList = ref<any[]>([]);
|
||||
const expandedRowKeys = ref<(string | number)[]>([]);
|
||||
|
||||
// 加载变更记录
|
||||
/**
|
||||
* 加载变更记录
|
||||
*/
|
||||
const loadChangeLog = async (page = 1, pageSize = 20) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = {
|
||||
const params: Record<string, any> = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
|
||||
if (filters.value.table_name) {
|
||||
params.table_name = filters.value.table_name;
|
||||
}
|
||||
@@ -56,7 +68,6 @@ const loadChangeLog = async (page = 1, pageSize = 20) => {
|
||||
if (filters.value.end_time) {
|
||||
params.end_time = filters.value.end_time;
|
||||
}
|
||||
|
||||
const res = await getChangeLogApi(params);
|
||||
changeLogList.value = res.items || [];
|
||||
pagination.value = {
|
||||
@@ -72,7 +83,6 @@ const loadChangeLog = async (page = 1, pageSize = 20) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 操作类型标签颜色
|
||||
const getOperationTypeColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
structure_change: 'blue',
|
||||
@@ -82,7 +92,6 @@ const getOperationTypeColor = (type: string) => {
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
// 操作类型标签文本
|
||||
const getOperationTypeText = (type: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
structure_change: '结构变更',
|
||||
@@ -92,31 +101,6 @@ const getOperationTypeText = (type: string) => {
|
||||
return textMap[type] || type;
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: number) => {
|
||||
if (!timestamp) return '-';
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
loadChangeLog(1, pagination.value.pageSize);
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
filters.value = {
|
||||
table_name: '',
|
||||
operation_type: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
};
|
||||
loadChangeLog(1, pagination.value.pageSize);
|
||||
};
|
||||
|
||||
// 展开详情
|
||||
const expandedRowKeys = ref<number[]>([]);
|
||||
|
||||
const getActionText = (action: string) => {
|
||||
const actionText: Record<string, string> = {
|
||||
update_table_comment: '更新表注释',
|
||||
@@ -135,34 +119,84 @@ const getActionText = (action: string) => {
|
||||
return actionText[action] || action;
|
||||
};
|
||||
|
||||
/**
|
||||
* 展示时间:后端已格式化为字符串则直接用,数值时间戳才本地转
|
||||
*/
|
||||
const displayTime = (value: unknown) => {
|
||||
if (value == null || value === '' || value === 0) return '-';
|
||||
if (typeof value === 'string' && Number.isNaN(Number(value))) {
|
||||
return value;
|
||||
}
|
||||
const num = Number(value);
|
||||
if (!Number.isNaN(num) && num > 1_000_000_000) {
|
||||
return new Date(num * 1000).toLocaleString('zh-CN');
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const startDate = ref<string | undefined>(undefined);
|
||||
const endDate = ref<string | undefined>(undefined);
|
||||
|
||||
const handleSearch = () => {
|
||||
filters.value.start_time = startDate.value || '';
|
||||
filters.value.end_time = endDate.value || '';
|
||||
loadChangeLog(1, pagination.value.pageSize);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
filters.value = {
|
||||
table_name: '',
|
||||
operation_type: undefined,
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
};
|
||||
startDate.value = undefined;
|
||||
endDate.value = undefined;
|
||||
loadChangeLog(1, pagination.value.pageSize);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '表名', dataIndex: 'table_name', key: 'table_name', width: 150 },
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 72, fixed: 'left' as const },
|
||||
{
|
||||
title: '表名',
|
||||
dataIndex: 'table_name',
|
||||
key: 'table_name',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'operation_type',
|
||||
key: 'operation_type',
|
||||
width: 120,
|
||||
width: 110,
|
||||
customRender: ({ text }: any) =>
|
||||
h(Tag, { color: getOperationTypeColor(text) }, () => getOperationTypeText(text)),
|
||||
h(Tag, { color: getOperationTypeColor(text) }, () =>
|
||||
getOperationTypeText(text),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作详情',
|
||||
dataIndex: 'operation_detail',
|
||||
key: 'operation_detail',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
customRender: ({ record }: any) => {
|
||||
const detail = record.operation_detail || {};
|
||||
const action = detail.action || '';
|
||||
return getActionText(action);
|
||||
return getActionText(detail.action || '');
|
||||
},
|
||||
},
|
||||
{ title: '操作用户', dataIndex: 'user_id', key: 'user_id', width: 100 },
|
||||
{
|
||||
title: '操作用户',
|
||||
dataIndex: 'user_id',
|
||||
key: 'user_id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
customRender: ({ text }: any) => formatTime(text),
|
||||
customRender: ({ text }: any) => displayTime(text),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -173,83 +207,91 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="change-log">
|
||||
<Card title="变更记录" class="mb-4">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">表名:</span>
|
||||
<input
|
||||
v-model="filters.table_name"
|
||||
type="text"
|
||||
placeholder="请输入表名"
|
||||
class="w-40 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">操作类型:</span>
|
||||
<select
|
||||
v-model="filters.operation_type"
|
||||
class="w-32 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option v-for="option in operationTypeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">开始时间:</span>
|
||||
<input
|
||||
v-model="filters.start_time"
|
||||
type="date"
|
||||
class="w-40 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">结束时间:</span>
|
||||
<input
|
||||
v-model="filters.end_time"
|
||||
type="date"
|
||||
class="w-40 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button type="primary" @click="handleSearch">搜索</Button>
|
||||
<Button @click="handleReset">重置</Button>
|
||||
</div>
|
||||
<div class="change-log__filters">
|
||||
<Input
|
||||
v-model:value="filters.table_name"
|
||||
allow-clear
|
||||
placeholder="表名"
|
||||
class="change-log__field"
|
||||
@press-enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="lucide:table-2" :size="14" />
|
||||
</template>
|
||||
</Input>
|
||||
<Select
|
||||
v-model:value="filters.operation_type"
|
||||
allow-clear
|
||||
placeholder="操作类型"
|
||||
class="change-log__field"
|
||||
:options="operationTypeOptions"
|
||||
/>
|
||||
<DatePicker
|
||||
v-model:value="startDate"
|
||||
class="change-log__field"
|
||||
placeholder="开始日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<DatePicker
|
||||
v-model:value="endDate"
|
||||
class="change-log__field"
|
||||
placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<div class="change-log__actions">
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<Icon icon="lucide:search" :size="14" class="mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button @click="handleReset">重置</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
class="change-log__table"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
:columns="columns"
|
||||
:data-source="changeLogList"
|
||||
:loading="loading"
|
||||
:scroll="{ x: 900 }"
|
||||
:pagination="{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
}"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
v-model:expandedRowKeys="expandedRowKeys"
|
||||
@change="(p: any) => loadChangeLog(p.current, p.pageSize)"
|
||||
>
|
||||
<template #expandedRowRender="{ record }">
|
||||
<div class="p-4 space-y-4">
|
||||
<div v-if="record.sql_statement">
|
||||
<div class="mb-2 font-semibold">SQL语句:</div>
|
||||
<pre class="rounded bg-gray-100 p-3 text-sm dark:bg-gray-800">{{ record.sql_statement }}</pre>
|
||||
<div class="change-log__detail">
|
||||
<div v-if="record.sql_statement" class="change-log__block">
|
||||
<div class="change-log__label">SQL 语句</div>
|
||||
<pre class="change-log__pre">{{ record.sql_statement }}</pre>
|
||||
</div>
|
||||
<div v-if="record.operation_detail">
|
||||
<div class="mb-2 font-semibold">操作详情:</div>
|
||||
<pre class="rounded bg-gray-100 p-3 text-sm dark:bg-gray-800">{{ JSON.stringify(record.operation_detail, null, 2) }}</pre>
|
||||
<div v-if="record.operation_detail" class="change-log__block">
|
||||
<div class="change-log__label">操作详情</div>
|
||||
<pre class="change-log__pre">{{
|
||||
JSON.stringify(record.operation_detail, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
<div v-if="record.before_data" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="mb-2 font-semibold">变更前:</div>
|
||||
<pre class="max-h-64 overflow-auto rounded bg-gray-100 p-3 text-sm dark:bg-gray-800">{{ JSON.stringify(record.before_data, null, 2) }}</pre>
|
||||
<div
|
||||
v-if="record.before_data || record.after_data"
|
||||
class="change-log__diff"
|
||||
>
|
||||
<div class="change-log__block">
|
||||
<div class="change-log__label">变更前</div>
|
||||
<pre class="change-log__pre change-log__pre--scroll">{{
|
||||
JSON.stringify(record.before_data ?? null, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 font-semibold">变更后:</div>
|
||||
<pre class="max-h-64 overflow-auto rounded bg-gray-100 p-3 text-sm dark:bg-gray-800">{{ JSON.stringify(record.after_data, null, 2) }}</pre>
|
||||
<div class="change-log__block">
|
||||
<div class="change-log__label">变更后</div>
|
||||
<pre class="change-log__pre change-log__pre--scroll">{{
|
||||
JSON.stringify(record.after_data ?? null, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,7 +301,85 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 1rem;
|
||||
.change-log__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
}
|
||||
|
||||
.change-log__field {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.change-log__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.change-log__table {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.change-log__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 8px 8px;
|
||||
}
|
||||
|
||||
.change-log__diff {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.change-log__label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.change-log__pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.change-log__pre--scroll {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.change-log__diff {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.change-log__field {
|
||||
width: 100%;
|
||||
min-width: 140px;
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
|
||||
.change-log__actions {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,14 +28,61 @@ const emit = defineEmits<{
|
||||
const selectedRowKeys = ref<any[]>([]);
|
||||
const searchConditions = ref<any[]>([]);
|
||||
|
||||
/**
|
||||
* 按字段类型估算合适列宽,避免表头被挤成一团
|
||||
*/
|
||||
const estimateColumnWidth = (col: any): number => {
|
||||
const type = String(col.type || col.data_type || '').toLowerCase();
|
||||
const name = String(col.name || '');
|
||||
const title = String(col.comment || col.name || '');
|
||||
const titleBased = Math.min(280, Math.max(100, title.length * 14 + 24));
|
||||
|
||||
if (name === 'id') return 80;
|
||||
if (
|
||||
name.endsWith('_at') ||
|
||||
name.includes('time') ||
|
||||
name.includes('date')
|
||||
) {
|
||||
return 170;
|
||||
}
|
||||
if (name.includes('status') || type.includes('tinyint') || type.includes('bool')) {
|
||||
return 100;
|
||||
}
|
||||
if (type.includes('text') || type.includes('json') || type.includes('blob')) {
|
||||
return Math.max(200, titleBased);
|
||||
}
|
||||
if (type.includes('varchar') || type.includes('char')) {
|
||||
const len = Number(col.length || col.character_maximum_length || 0);
|
||||
if (len > 0 && len <= 32) return Math.max(120, titleBased);
|
||||
if (len > 128) return Math.max(200, titleBased);
|
||||
return Math.max(140, titleBased);
|
||||
}
|
||||
if (
|
||||
type.includes('int') ||
|
||||
type.includes('decimal') ||
|
||||
type.includes('float') ||
|
||||
type.includes('double')
|
||||
) {
|
||||
return Math.max(110, Math.min(140, titleBased));
|
||||
}
|
||||
return titleBased;
|
||||
};
|
||||
|
||||
const tableColumns = computed(() => {
|
||||
if (!props.tableInfo?.columns) return [];
|
||||
const dataCols = props.tableInfo.columns.map((col: any) => ({
|
||||
title: col.comment || col.name,
|
||||
dataIndex: col.name,
|
||||
key: col.name,
|
||||
width: estimateColumnWidth(col),
|
||||
ellipsis: true,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
title: '',
|
||||
key: 'selection',
|
||||
width: 50,
|
||||
fixed: 'left',
|
||||
fixed: 'left' as const,
|
||||
customRender: ({ record }: any) => {
|
||||
const key = getRowKey(record);
|
||||
return h('input', {
|
||||
@@ -54,39 +101,57 @@ const tableColumns = computed(() => {
|
||||
});
|
||||
},
|
||||
},
|
||||
...props.tableInfo.columns.map((col: any) => ({
|
||||
title: col.comment || col.name,
|
||||
dataIndex: col.name,
|
||||
key: col.name,
|
||||
})),
|
||||
...dataCols,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
fixed: 'right' as const,
|
||||
customRender: ({ record }: any) =>
|
||||
h('div', { class: 'flex gap-2' }, [
|
||||
h(Button, {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: () => emit('edit-data', record),
|
||||
}, () => '编辑'),
|
||||
h(Popconfirm, {
|
||||
title: '确定删除这条数据吗?',
|
||||
onConfirm: () => emit('delete-data', record),
|
||||
}, {
|
||||
default: () =>
|
||||
h(Button, {
|
||||
type: 'link',
|
||||
danger: true,
|
||||
size: 'small',
|
||||
}, () => '删除'),
|
||||
}),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: () => emit('edit-data', record),
|
||||
},
|
||||
() => '编辑',
|
||||
),
|
||||
h(
|
||||
Popconfirm,
|
||||
{
|
||||
title: '确定删除这条数据吗?',
|
||||
onConfirm: () => emit('delete-data', record),
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'link',
|
||||
danger: true,
|
||||
size: 'small',
|
||||
},
|
||||
() => '删除',
|
||||
),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
/** 横向滚动宽度:列宽合计,保证表头不被压扁 */
|
||||
const tableScrollX = computed(() => {
|
||||
if (!props.tableInfo?.columns?.length) return 800;
|
||||
const dataWidth = props.tableInfo.columns.reduce(
|
||||
(sum: number, col: any) => sum + estimateColumnWidth(col),
|
||||
0,
|
||||
);
|
||||
return 50 + dataWidth + 150;
|
||||
});
|
||||
|
||||
// 获取行的唯一标识
|
||||
const getRowKey = (record: any) => {
|
||||
// 尝试使用id字段,如果没有则使用第一个字段
|
||||
@@ -166,6 +231,7 @@ const handleResetSearch = () => {
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:row-key="getRowKey"
|
||||
:scroll="{ x: tableScrollX }"
|
||||
:pagination="{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
|
||||
Reference in New Issue
Block a user