按原型图重构核心页面并完善生成链路
This commit is contained in:
+157
-36
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user