按原型图重构核心页面并完善生成链路

This commit is contained in:
zwt13703
2026-07-02 16:09:34 +08:00
parent 5a43cc70d5
commit a8716aa3c6
10 changed files with 1228 additions and 210 deletions
+157 -36
View File
@@ -1,51 +1,172 @@
<template>
<a-config-provider :theme="{ token: { colorPrimary: '#5b5bd6', borderRadius: 8 } }">
<a-layout style="min-height:100vh">
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible theme="light" :width="220">
<div class="logo">{{ collapsed ? "A" : "AI 文档模板" }}</div>
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="onMenuClick">
<a-menu-item key="/templates"><folder-outlined /> 模板管理</a-menu-item>
<a-menu-item key="/models"><api-outlined /> 模型管理</a-menu-item>
<a-menu-item key="/generate"><thunderbolt-outlined /> 执行生成</a-menu-item>
<a-menu-item key="/history"><clock-circle-outlined /> 生成记录</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header style="background:#fff;padding:0 24px;display:flex;align-items:center;border-bottom:1px solid #f0f0f0">
<menu-unfold-outlined v-if="collapsed" @click="collapsed=false" style="font-size:18px;cursor:pointer" />
<menu-fold-outlined v-else @click="collapsed=true" style="font-size:18px;cursor:pointer" />
<div style="flex:1" />
<a-badge :count="0" :overflow-count="99"><bell-outlined style="font-size:18px;color:#999" /></a-badge>
<span style="margin-left:16px;font-size:13px;color:#666">周文涛</span>
</a-layout-header>
<a-layout-content style="margin:0;background:#f5f6f8;overflow:auto">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
<div class="app-shell">
<header class="topbar">
<div class="topbar-logo">
<div class="logo-icon">A</div>
<span>AI 文档模板</span>
</div>
<nav class="topbar-nav">
<button :class="['topbar-tab', { active: isActive('/templates') }]" @click="router.push('/templates')">
<folder-outlined />
模板管理
</button>
<button :class="['topbar-tab', { active: isActive('/models') }]" @click="router.push('/models')">
<api-outlined />
模型管理
</button>
<button :class="['topbar-tab', { active: isActive('/generate') }]" @click="router.push('/generate')">
<thunderbolt-outlined />
执行生成
</button>
<button :class="['topbar-tab', { active: isActive('/history') }]" @click="router.push('/history')">
<clock-circle-outlined />
生成记录
</button>
</nav>
<div class="topbar-right">
<span class="version-text">v3.0</span>
<div class="user-badge">
<span class="user-name">周文涛</span>
<div class="user-avatar-sm"></div>
</div>
</div>
</header>
<main class="page-main">
<router-view />
</main>
</div>
</a-config-provider>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined,
MenuUnfoldOutlined, MenuFoldOutlined, BellOutlined
} from '@ant-design/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const collapsed = ref(false)
const selectedKeys = ref([route.path])
watch(() => route.path, p => { selectedKeys.value = [p] })
function onMenuClick(info: any) {
router.push(info.key)
function isActive(path: string) {
return route.path.startsWith(path)
}
</script>
<style scoped>
.logo { height: 52px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: #5b5bd6; border-bottom: 1px solid #f0f0f0; }
.app-shell {
min-height: 100vh;
background: #f5f6f8;
color: #1a1d24;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
}
.topbar {
height: 52px;
background: #fff;
border-bottom: 1px solid #e0e2e6;
display: flex;
align-items: center;
padding: 0 24px;
gap: 12px;
}
.topbar-logo {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15px;
margin-right: 16px;
flex-shrink: 0;
}
.logo-icon {
width: 28px;
height: 28px;
background: #5b5bd6;
border-radius: 6px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
}
.topbar-nav {
display: flex;
align-items: center;
gap: 4px;
}
.topbar-tab {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
color: #5b626e;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.12s;
}
.topbar-tab:hover {
background: #f0f1f3;
color: #1a1d24;
}
.topbar-tab.active {
background: #eeeefb;
color: #5b5bd6;
font-weight: 500;
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.version-text {
font-size: 12px;
color: #9aa1ad;
}
.user-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 12px;
background: #f0f1f3;
border-radius: 20px;
}
.user-name {
font-size: 12px;
font-weight: 500;
}
.user-avatar-sm {
width: 22px;
height: 22px;
border-radius: 50%;
background: #eeeefb;
color: #5b5bd6;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.page-main {
height: calc(100vh - 52px);
overflow: auto;
}
</style>
+440 -27
View File
@@ -1,29 +1,442 @@
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f: File)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small" :loading="uploadingMap[p.id]">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template>
<template>
<div class="page-wrap">
<div class="breadcrumb">
<span>执行生成</span>
<span>/</span>
<span>选择模板 上传文件 AI 自动生成</span>
</div>
<div class="gen-layout">
<div class="gen-left">
<div class="panel-card">
<div class="panel-head">
<div class="panel-title">选择模板</div>
</div>
<div class="panel-body">
<a-select style="width:100%" v-model:value="selectedTplId" placeholder="— 请选择已编辑好的模板 —" @change="onTplChange">
<a-select-option v-for="item in templates" :key="item.id" :value="item.id">{{ item.name }}</a-select-option>
</a-select>
<div class="template-summary" v-if="selectedTplId">
<div class="summary-name">{{ currentTemplateName }}</div>
<div class="summary-desc">{{ tplInfo.paragraph_count || 0 }} 个段落 · {{ needFileCount }} 个需上传文件</div>
</div>
<div class="summary-stats">
<div class="stat-box">
<div class="stat-num">{{ tplInfo.paragraph_count || 0 }}</div>
<div class="stat-label">总段落</div>
</div>
<div class="stat-box success">
<div class="stat-num">{{ autoCount }}</div>
<div class="stat-label">自动生成</div>
</div>
<div class="stat-box warning">
<div class="stat-num">{{ needFileCount }}</div>
<div class="stat-label">需要文件</div>
</div>
</div>
</div>
</div>
<div v-if="generating" class="status-block">
<div class="gen-progress">
<div class="spinner" />
<div class="gen-text">{{ progressText }}</div>
</div>
<div class="progress-card">
<div class="progress-title">生成进度</div>
<a-progress :percent="progress" />
<a-button danger block @click="cancelGen">取消生成</a-button>
</div>
</div>
</div>
<div class="gen-right">
<div class="panel-card fill-card">
<div class="panel-head">
<div class="panel-title">段落文件配置</div>
</div>
<div class="panel-body">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :class="['para-row', { needFile: paragraph.need_file, noFile: !paragraph.need_file }]">
<div class="para-info">
<span class="idx">{{ paragraph.sort_index }}</span>
<span>{{ paragraph.title }}</span>
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div>
<div v-if="paragraph.need_file" class="file-info">
<a-upload :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id] ? '已上传' : '上传文件' }}</a-button>
</a-upload>
<span v-if="uploadedFiles[paragraph.id]" class="uploaded-name">{{ uploadedFiles[paragraph.id] }}</span>
</div>
<span v-else class="no-file-tag">无需上传</span>
</div>
</div>
</div>
<div class="action-bar">
<span>{{ fileCount }}/{{ needFileCount }} 个文件已上传</span>
<a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { useDocumentStore } from "@/stores/document";
import { generateApi } from "@/api/generate";
import { message } from "ant-design-vue";
const router = useRouter();
const tplStore = useTemplateStore();
const docStore = useDocumentStore();
const templates = ref<any[]>([]);
const paragraphs = ref<any[]>([]);
const selectedTplId = ref(undefined);
const uploadedFiles = ref<Record<number,string>>({});
const uploadedFilePaths = ref<Record<number,string>>({});
const uploadingMap = ref<Record<number,boolean>>({});
const generating = ref(false);
const progress = ref(0);
const progressText = ref("");
const tplInfo = ref<any>({});
const needFileCount = computed(()=>paragraphs.value.filter(p=>p.need_file).length);
const fileCount = computed(()=>Object.keys(uploadedFiles.value).length);
onMounted(async()=>{await tplStore.fetchList();templates.value=tplStore.templates as any});
async function onTplChange(id:number){const tpl=await tplStore.fetchOne(id);paragraphs.value=tplStore.paragraphs as any;tplInfo.value={paragraph_count:tpl.paragraph_count,fileCount:paragraphs.value.filter((p:any)=>p.need_file).length};uploadedFiles.value={};uploadedFilePaths.value={};}
async function handleFileUpload(paraId:number,file:File){try{uploadingMap.value[paraId]=true;const fd=new FormData();fd.append("file",file);const res:any=await generateApi.upload(fd);uploadedFiles.value[paraId]=res.data.file_name;uploadedFilePaths.value[paraId]=res.data.file_path;message.success("文件上传成功")}catch(e:any){message.error(e.message||"文件上传失败")}finally{uploadingMap.value[paraId]=false}return false}
async function startGen(){if(!selectedTplId.value){message.warning("请先选择模板");return}const missing=paragraphs.value.filter((p:any)=>p.need_file&&!uploadedFilePaths.value[p.id]);if(missing.length){message.warning("还有必传文件未上传");return}generating.value=true;progress.value=30;progressText.value="正在生成...";const fileMap=Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([k,v])=>[String(k),v]));const data={template_id:selectedTplId.value,file_map:fileMap};try{const doc=await docStore.generateFull(data);progress.value=100;progressText.value="生成完成";message.success("生成完成");router.push(`/preview/${doc.id}`)}catch(e:any){message.error(e.message||"生成失败")}finally{generating.value=false}}
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template'
import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate'
const route = useRoute()
const router = useRouter()
const tplStore = useTemplateStore()
const docStore = useDocumentStore()
const templates = ref<any[]>([])
const paragraphs = ref<any[]>([])
const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, string>>({})
const uploadedFilePaths = ref<Record<number, string>>({})
const uploadingMap = ref<Record<number, boolean>>({})
const generating = ref(false)
const progress = ref(0)
const progressText = ref('')
const tplInfo = ref<any>({})
const currentDocumentId = ref<number | null>(null)
let progressSource: EventSource | null = null
const needFileCount = computed(() => paragraphs.value.filter((item) => item.need_file).length)
const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').length)
const fileCount = computed(() => Object.keys(uploadedFiles.value).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
async function refreshTemplates() {
await tplStore.fetchList()
templates.value = tplStore.templates as any
}
function closeProgressSource() {
if (progressSource) {
progressSource.close()
progressSource = null
}
}
function bindProgress(documentId: number) {
closeProgressSource()
progressSource = new EventSource(generateApi.progress(documentId))
progressSource.addEventListener('progress', (event: MessageEvent) => {
const payload = JSON.parse(event.data)
progress.value = payload.percent || 0
progressText.value = payload.message || '正在生成...'
if (payload.status === 'completed' || payload.status === 'failed') {
generating.value = false
closeProgressSource()
message.success(payload.status === 'completed' ? '生成完成' : '生成结束,部分段落已回退为模拟结果')
router.push(`/preview/${documentId}`)
} else if (payload.status === 'cancelled') {
generating.value = false
closeProgressSource()
message.info('已取消生成')
}
})
progressSource.onerror = () => {
closeProgressSource()
}
}
onMounted(async () => {
await refreshTemplates()
const queryId = Number(route.query.templateId)
if (queryId) {
selectedTplId.value = queryId
await onTplChange(queryId)
}
})
onBeforeUnmount(() => {
closeProgressSource()
})
async function onTplChange(id: number) {
const template = await tplStore.fetchOne(id)
paragraphs.value = tplStore.paragraphs as any
tplInfo.value = {
paragraph_count: template.paragraph_count,
fileCount: paragraphs.value.filter((item: any) => item.need_file).length,
}
uploadedFiles.value = {}
uploadedFilePaths.value = {}
}
async function handleFileUpload(paragraphId: number, file: File) {
try {
uploadingMap.value[paragraphId] = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
uploadedFiles.value[paragraphId] = res.data.file_name
uploadedFilePaths.value[paragraphId] = res.data.file_path
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
} finally {
uploadingMap.value[paragraphId] = false
}
return false
}
async function startGen() {
if (!selectedTplId.value) {
message.warning('请先选择模板')
return
}
const missing = paragraphs.value.filter((item: any) => item.need_file && !uploadedFilePaths.value[item.id])
if (missing.length) {
message.warning('还有必传文件未上传')
return
}
generating.value = true
progress.value = 0
progressText.value = '任务创建中...'
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value]))
try {
const document: any = await docStore.generateFull({ template_id: selectedTplId.value, file_map: fileMap })
currentDocumentId.value = document.id
bindProgress(document.id)
} catch (error: any) {
generating.value = false
message.error(error.message || '生成失败')
}
}
async function cancelGen() {
if (!currentDocumentId.value) return
await docStore.cancel(currentDocumentId.value)
progressText.value = '正在取消...'
}
</script>
<style scoped>.para-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid #f0f0f0;border-radius:8px;margin-bottom:8px}.para-row.needFile{background:#fff;border-color:#d9d9d9}.para-row.noFile{background:#fafafa;border-style:dashed;opacity:.7}.para-info{display:flex;align-items:center;gap:8px}.para-info .idx{width:22px;height:22px;border-radius:50%;background:#f0f0ff;color:#5b5bd6;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.no-file-tag{font-size:12px;color:#999}</style>
<style scoped>
.page-wrap {
padding: 24px 32px 32px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9aa1ad;
margin-bottom: 16px;
}
.gen-layout {
display: flex;
gap: 24px;
min-height: calc(100vh - 140px);
}
.gen-left {
width: 340px;
flex-shrink: 0;
}
.gen-right {
flex: 1;
display: flex;
flex-direction: column;
}
.panel-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e0e2e6;
overflow: hidden;
}
.fill-card {
flex: 1;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #eaecef;
}
.panel-title {
font-size: 15px;
font-weight: 600;
}
.panel-body {
padding: 24px;
}
.template-summary {
background: #f0f1f3;
border-radius: 8px;
padding: 12px;
margin-top: 12px;
}
.summary-name {
font-weight: 500;
margin-bottom: 4px;
}
.summary-desc {
font-size: 12px;
color: #9aa1ad;
}
.summary-stats {
display: flex;
gap: 12px;
margin-top: 12px;
}
.stat-box {
flex: 1;
text-align: center;
padding: 8px;
background: #f0f1f3;
border-radius: 6px;
}
.stat-box.success {
background: #e8f5e9;
}
.stat-box.warning {
background: #fff3e0;
}
.stat-num {
font-size: 18px;
font-weight: 700;
color: #5b5bd6;
}
.stat-label {
font-size: 12px;
color: #9aa1ad;
}
.status-block {
margin-top: 16px;
}
.gen-progress {
background: #fff;
border: 1px solid #e0e2e6;
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 16px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e2e6;
border-top-color: #5b5bd6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
.gen-text {
font-size: 13px;
color: #5b626e;
}
.progress-card {
background: #fff;
border: 1px solid #e0e2e6;
border-radius: 8px;
padding: 16px;
margin-top: 12px;
}
.progress-title {
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
}
.para-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
margin-bottom: 8px;
}
.para-row.needFile {
background: #fff;
border-color: #d9d9d9;
}
.para-row.noFile {
background: #fafafa;
border-style: dashed;
opacity: 0.7;
}
.para-info {
display: flex;
align-items: center;
gap: 8px;
}
.idx {
width: 22px;
height: 22px;
border-radius: 50%;
background: #f0f0ff;
color: #5b5bd6;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.uploaded-name {
color: #1a8c4a;
margin-left: 8px;
}
.no-file-tag {
font-size: 12px;
color: #999;
}
.action-bar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+128 -33
View File
@@ -1,33 +1,40 @@
<template>
<div style="padding:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2>模型管理</h2>
<a-button type="primary" @click="openAdd">添加模型</a-button>
<div class="page-wrap">
<div class="breadcrumb">
<span>模型管理</span>
<span>/</span>
<span>AI 模型配置</span>
</div>
<a-row :gutter="[16, 16]">
<a-col :span="8" v-for="item in models" :key="item.id">
<a-card :title="item.name">
<template #extra>
<a-button type="link" size="small" @click="openEdit(item)">编辑</a-button>
</template>
<p>厂商{{ item.provider }}</p>
<p>格式{{ item.api_format }}</p>
<p>地址{{ item.api_endpoint }}</p>
<p>密钥{{ item.api_key_preview || '未设置' }}</p>
<p>状态<a-switch :checked="item.status === 'enabled'" @change="toggleStatus(item)" /></p>
<div style="margin-top:12px;display:flex;gap:8px">
<a-button size="small" @click="runTest(item)">连接测试</a-button>
<a-button danger size="small" @click="removeModel(item.id)">删除</a-button>
<div class="panel-card">
<div class="panel-head">
<div class="panel-title">可用模型列表</div>
<a-button @click="openAdd">添加模型</a-button>
</div>
<div class="panel-body">
<div class="model-card" v-for="item in models" :key="item.id">
<div class="mc-icon">{{ item.name?.slice(0, 1)?.toUpperCase() || 'M' }}</div>
<div class="mc-info">
<div class="mc-name">{{ item.name }}</div>
<div class="mc-provider">{{ item.provider }} · {{ item.api_endpoint }}</div>
<div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div>
</div>
</a-card>
</a-col>
</a-row>
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
</div>
<div class="mc-actions">
<a-button size="small" @click="runTest(item)">测试</a-button>
<a-button size="small" @click="openEdit(item)">编辑</a-button>
<a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button>
</div>
</div>
</div>
</div>
<a-modal v-model:open="modalOpen" :title="isEdit ? '编辑模型' : '添加模型'" @ok="saveModel">
<a-form layout="vertical">
<a-form-item label="模型名称">
<a-input v-model:value="form.name" placeholder="如 gpt-4o-mini / deepseek-chat / claude-3-5-sonnet-latest" />
<a-input v-model:value="form.name" />
</a-form-item>
<a-form-item label="供应厂商">
<a-input v-model:value="form.provider" />
@@ -39,10 +46,10 @@
</a-select>
</a-form-item>
<a-form-item label="API 地址">
<a-input v-model:value="form.api_endpoint" placeholder="https://api.openai.com 或兼容网关地址" />
<a-input v-model:value="form.api_endpoint" />
</a-form-item>
<a-form-item label="API Key">
<a-input-password v-model:value="form.api_key" placeholder="编辑时留空表示保持原密钥不变" />
<a-input-password v-model:value="form.api_key" />
</a-form-item>
</a-form>
</a-modal>
@@ -101,8 +108,7 @@ async function saveModel() {
}
async function toggleStatus(item: any) {
const nextStatus = item.status === 'enabled' ? 'disabled' : 'enabled'
await store.update(item.id, { status: nextStatus })
await store.update(item.id, { status: item.status === 'enabled' ? 'disabled' : 'enabled' })
await refreshList()
message.success('状态已更新')
}
@@ -112,17 +118,106 @@ async function runTest(item: any) {
const result: any = await store.test(item.id)
Modal.info({
title: '连接测试结果',
width: 640,
width: 680,
content: JSON.stringify(result.data, null, 2),
})
} catch (error: any) {
message.error(error.message || '连接测试失败')
}
}
async function removeModel(id: number) {
await store.remove(id)
await refreshList()
message.success('模型已删除')
}
</script>
<style scoped>
.page-wrap {
padding: 24px 32px 32px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9aa1ad;
margin-bottom: 16px;
}
.panel-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e0e2e6;
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #eaecef;
}
.panel-title {
font-size: 15px;
font-weight: 600;
}
.panel-body {
padding: 24px;
}
.model-card {
border: 1px solid #eaecef;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
}
.mc-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: #eeeefb;
color: #5b5bd6;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
flex-shrink: 0;
}
.mc-info {
flex: 1;
min-width: 0;
}
.mc-name {
font-size: 14px;
font-weight: 500;
}
.mc-provider {
font-size: 12px;
color: #9aa1ad;
}
.mc-status {
font-size: 12px;
white-space: nowrap;
}
.mc-status.on {
color: #1a8c4a;
}
.mc-status.off {
color: #9aa1ad;
}
.mc-actions {
display: flex;
gap: 8px;
}
</style>
+233 -16
View File
@@ -1,17 +1,234 @@
<template><div style="padding:24px"><h2>模板管理</h2><a-button type="primary" @click="handleUpload">新建模板</a-button><a-table :dataSource="templates" :columns="columns" rowKey="id" style="margin-top:16px" /><a-modal v-model:open="uploadOpen" title="上传模板" @ok="doUpload"><a-upload-dragger :beforeUpload="beforeUpload"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击或拖拽 Word 模板文件到此区域</p></a-upload-dragger></a-modal></div></template>
<template>
<div class="page-wrap">
<div class="breadcrumb">
<span>模板管理</span>
<span>/</span>
<span>所有模板</span>
</div>
<div class="page-head">
<span class="page-subtitle"> {{ templates.length }} 个模板</span>
<a-button type="primary" @click="handleUpload">
<template #icon><file-add-outlined /></template>
新建模板
</a-button>
</div>
<div class="template-grid">
<div
v-for="item in templates"
:key="item.id"
class="tpl-card"
@click="goEdit(item.id)"
>
<div class="tpl-name">
<file-text-outlined class="tpl-icon" />
{{ item.name }}
<span :class="['tpl-status', item.status === 'ready' ? 'ready' : 'editing']">
{{ item.status === 'ready' ? '已编辑' : '编辑中' }}
</span>
</div>
<div class="tpl-desc">
{{ item.description || `${item.paragraph_count || 0} 个段落,可继续编辑配置。` }}
</div>
<div class="tpl-meta">
<span>段落{{ item.paragraph_count || 0 }}</span>
<span>状态{{ item.status }}</span>
</div>
<div class="tpl-actions" @click.stop>
<a-button size="small" @click="goEdit(item.id)">编辑模板</a-button>
<a-button size="small" type="primary" @click="goGenerate(item.id)">前去生成</a-button>
<a-popconfirm title="确认删除这个模板吗?" @confirm="removeTemplate(item.id)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</div>
</div>
<div class="tpl-card tpl-card-create" @click="handleUpload">
<div class="tpl-name">
<plus-square-outlined class="tpl-icon muted" />
新建模板
<span class="tpl-status empty">未开始</span>
</div>
<div class="tpl-desc">点击创建新模板上传 Word 文件配置段落</div>
<div class="tpl-meta">
<span>段落0</span>
</div>
<div class="tpl-actions">
<a-button size="small" type="primary">新建模板</a-button>
</div>
</div>
</div>
<a-modal v-model:open="uploadOpen" title="上传模板" @ok="doUpload">
<a-upload-dragger :beforeUpload="beforeUpload">
<p class="ant-upload-drag-icon"><file-add-outlined /></p>
<p class="ant-upload-text">点击或拖拽 Word 模板文件到此区域</p>
</a-upload-dragger>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { FileAddOutlined } from "@ant-design/icons-vue";
const router = useRouter();
const store = useTemplateStore();
const templates = ref([]);
const uploadOpen = ref(false);
const uploadFile = ref<File | null>(null);
const columns = [{title:"名称",dataIndex:"name"},{title:"段落",dataIndex:"paragraph_count"},{title:"状态",dataIndex:"status"},{title:"操作",key:"action"}];
onMounted(async()=>{await store.fetchList();templates.value=store.templates as any});
function handleUpload(){uploadOpen.value=true}
function beforeUpload(file:File){uploadFile.value=file;return false}
async function doUpload(){if(!uploadFile.value)return;await store.upload(uploadFile.value);uploadOpen.value=false;router.push(`/templates/${store.currentTemplate?.id}/edit`)}
</script>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { FileAddOutlined, FileTextOutlined, PlusSquareOutlined } from '@ant-design/icons-vue'
import { useTemplateStore } from '@/stores/template'
const router = useRouter()
const store = useTemplateStore()
const templates = ref<any[]>([])
const uploadOpen = ref(false)
const uploadFile = ref<File | null>(null)
async function refreshList() {
await store.fetchList()
templates.value = store.templates as any
}
onMounted(async () => {
await refreshList()
})
function handleUpload() {
uploadOpen.value = true
}
function beforeUpload(file: File) {
uploadFile.value = file
return false
}
function goEdit(id: number) {
router.push(`/templates/${id}/edit`)
}
function goGenerate(id: number) {
router.push(`/generate?templateId=${id}`)
}
async function removeTemplate(id: number) {
await store.remove(id)
await refreshList()
message.success('模板已删除')
}
async function doUpload() {
if (!uploadFile.value) return
await store.upload(uploadFile.value)
uploadOpen.value = false
router.push(`/templates/${store.currentTemplate?.id}/edit`)
}
</script>
<style scoped>
.page-wrap {
padding: 24px 32px 32px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9aa1ad;
margin-bottom: 16px;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-subtitle {
font-size: 13px;
color: #5b626e;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.tpl-card {
border: 1px solid #eaecef;
border-radius: 12px;
background: #fff;
padding: 24px;
cursor: pointer;
transition: all 0.12s;
}
.tpl-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #7c7cdb;
}
.tpl-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.tpl-icon {
color: #5b5bd6;
}
.tpl-icon.muted {
color: #9aa1ad;
}
.tpl-desc {
font-size: 12px;
color: #9aa1ad;
margin-bottom: 12px;
min-height: 36px;
}
.tpl-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #9aa1ad;
}
.tpl-actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
.tpl-status {
font-size: 10px;
padding: 2px 10px;
border-radius: 10px;
margin-left: auto;
}
.tpl-status.ready {
background: #e8f5e9;
color: #1a8c4a;
}
.tpl-status.editing {
background: #fff3e0;
color: #d48a00;
}
.tpl-status.empty {
background: #f0f1f3;
color: #9aa1ad;
}
.tpl-card-create {
border-style: dashed;
}
</style>