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

This commit is contained in:
李琦
2026-08-10 15:51:04 +08:00
parent 089fa2f3eb
commit e8f30a1275
18 changed files with 3273 additions and 558 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)',

View File

@@ -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',
},

View File

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

View File

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

View File

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

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

View File

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

View 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>

View File

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

View File

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

View 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>

View File

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

View File

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