This commit is contained in:
2026-04-24 16:02:16 +08:00
commit 1d6f0cc370
66 changed files with 9990 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Story Extractor - 直播文字稿故事提取工具</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1407
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.15.1",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.9"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

78
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,78 @@
<template>
<div id="app-root">
<AppHeader @open-settings="showSettings = true" />
<AppStepper v-if="currentStep > 0" :current-step="currentStep" />
<main class="main-area">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<component :is="Component" ref="currentViewRef" @next="handleNext" @restart="handleRestart" />
</transition>
</router-view>
</main>
<BottomNav v-if="currentStep > 0" :current-step="currentStep" :hint="stepHints[currentStep]"
:disabled="isExtracting" @next="handleNext" />
<SettingsModal v-if="showSettings" @close="showSettings = false" />
<Toast />
</div>
</template>
<script setup>
import { ref, computed, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import AppHeader from './components/AppHeader.vue'
import AppStepper from './components/AppStepper.vue'
import BottomNav from './components/BottomNav.vue'
import SettingsModal from './components/SettingsModal.vue'
import Toast from './components/Toast.vue'
const router = useRouter()
const route = useRoute()
const showSettings = ref(false)
const currentViewRef = ref(null)
const isExtracting = ref(false)
provide('setExtracting', (val) => { isExtracting.value = val })
const stepMap = { '/': 0, '/upload': 1, '/extract': 2, '/match': 3, '/export': 4 }
const currentStep = computed(() => stepMap[route.path] ?? 0)
const stepHints = {
0: '欢迎使用故事提取工具',
1: '上传文字稿和讲述者信息文件后进入下一步',
2: 'AI 将自动从文字稿中提取个人故事',
3: '将提取的故事与讲述者一一对应',
4: '预览最终文档效果并导出',
}
function handleNext() {
if (isExtracting.value) return
if (currentStep.value === 4) {
if (currentViewRef.value && currentViewRef.value.handleDownloadAll) {
currentViewRef.value.handleDownloadAll()
}
} else if (currentStep.value < 4) {
router.push(['/', '/upload', '/extract', '/match', '/export'][currentStep.value + 1])
}
}
function handleRestart() {
if (isExtracting.value) return
localStorage.removeItem('extraction_task_id')
router.push('/')
}
</script>
<style>
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(12px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-12px);
}
</style>

60
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,60 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/v1',
timeout: 300000,
})
// ===== Files =====
export const uploadTranscripts = (files) => {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
return api.post('/files/transcripts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export const uploadPersons = (files) => {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
return api.post('/files/persons', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export const getTranscripts = () => api.get('/files/transcripts')
export const getPersons = () => api.get('/files/persons')
export const deleteTranscript = (id) => api.delete(`/files/transcripts/${id}`)
export const deletePerson = (id) => api.delete(`/files/persons/${id}`)
export const previewPerson = (id) => api.get(`/files/persons/${id}/preview`)
// ===== Extraction =====
export const startExtraction = (taskId) => api.post('/extraction/start', null, { params: taskId ? { task_id: taskId } : {} })
export const getExtractionStatus = (taskId) => api.get('/extraction/status', { params: taskId ? { task_id: taskId } : {} })
export const streamExtractionStatus = (taskId) => {
const url = taskId
? `/api/v1/extraction/status/stream?task_id=${taskId}`
: '/api/v1/extraction/status/stream'
return new EventSource(url)
}
export const stopExtraction = (taskId) => api.post('/extraction/stop', null, { params: taskId ? { task_id: taskId } : {} })
// ===== Stories =====
export const getStories = (status = 'all') => api.get('/stories', { params: { status } })
export const getStory = (id) => api.get(`/stories/${id}`)
export const editStory = (id, data) => api.put(`/stories/${id}`, data)
export const deleteStory = (id) => api.delete(`/stories/${id}`)
// ===== Matching =====
export const createMatch = (storyId, personId) => api.post('/matches', { story_id: storyId, person_id: personId })
export const removeMatch = (storyId) => api.delete(`/matches/${storyId}`)
export const getMatches = () => api.get('/matches')
// ===== Export =====
export const getExportList = () => api.get('/export/list')
export const previewExport = (personId) => api.get(`/export/preview/${personId}`)
export const downloadOne = (personId) => api.get(`/export/download/${personId}`, { responseType: 'blob' })
export const downloadAll = () => api.get('/export/download-all', { responseType: 'blob' })
// ===== Settings =====
export const getSettings = () => api.get('/settings')
export const updateSettings = (data) => api.put('/settings', data)
export const testLlm = () => api.post('/settings/test-llm')
export default api

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,936 @@
* { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; box-sizing: border-box; }
:root {
--primary: #6366f1;
--primary-light: #818cf8;
--primary-dark: #4f46e5;
--primary-bg: #eef2ff;
--accent: #f59e0b;
--success: #10b981;
--success-bg: #ecfdf5;
--danger: #ef4444;
--bg: #f1f5f9;
--card: #ffffff;
--border: #e2e8f0;
--border-light: #f1f5f9;
--text: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
}
body {
background: var(--bg);
color: var(--text);
margin: 0;
min-height: 100vh;
}
/* ===== HEADER ===== */
.app-header {
background: white;
border-bottom: 1px solid var(--border);
padding: 0 40px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.app-logo {
display: flex;
align-items: center;
gap: 12px;
}
.app-logo-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.app-logo-text {
font-size: 17px;
font-weight: 700;
color: var(--text);
}
.app-logo-text span {
color: var(--text-muted);
font-weight: 400;
font-size: 13px;
margin-left: 8px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--border);
background: white;
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s;
}
.header-btn:hover {
border-color: var(--primary-light);
color: var(--primary);
background: var(--primary-bg);
}
/* ===== STEPPER ===== */
.stepper-wrapper {
background: white;
border-bottom: 1px solid var(--border);
padding: 0 40px;
}
.stepper {
display: flex;
align-items: center;
max-width: 800px;
margin: 0 auto;
padding: 20px 0;
}
.stepper-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
cursor: pointer;
}
.stepper-step:not(:last-child)::after {
content: '';
position: absolute;
top: 18px;
left: calc(50% + 24px);
width: calc(100% - 48px);
height: 3px;
background: var(--border);
border-radius: 2px;
transition: background 0.4s;
}
.stepper-step.completed:not(:last-child)::after {
background: var(--success);
}
.stepper-step.active:not(:last-child)::after {
background: linear-gradient(90deg, var(--success) 50%, var(--border) 50%);
}
.step-circle {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
background: var(--border-light);
color: var(--text-muted);
border: 3px solid white;
box-shadow: 0 0 0 1px var(--border);
transition: all 0.3s;
position: relative;
z-index: 2;
}
.stepper-step.active .step-circle {
background: var(--primary);
color: white;
box-shadow: 0 0 0 3px var(--primary-bg), 0 4px 12px rgba(99,102,241,0.25);
}
.stepper-step.completed .step-circle {
background: var(--success);
color: white;
box-shadow: 0 0 0 3px var(--success-bg);
}
.step-label {
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
transition: color 0.3s;
}
.stepper-step.active .step-label {
color: var(--primary);
}
.stepper-step.completed .step-label {
color: var(--success);
}
/* ===== MAIN CONTENT ===== */
.main-area {
max-width: 1100px;
margin: 0 auto;
padding: 28px 40px 120px;
}
.step-page { display: none; }
.step-page.active { display: block; animation: fadeSlideIn 0.35s ease; }
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== BOTTOM NAV ===== */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 1px solid var(--border);
padding: 14px 40px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
}
.bottom-nav-hint {
font-size: 13px;
color: var(--text-muted);
}
.bottom-nav-actions {
display: flex;
gap: 10px;
}
/* ===== BUTTONS ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
white-space: nowrap;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
box-shadow: 0 4px 14px rgba(99,102,241,0.3);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover { background: #059669; }
.btn-outline {
background: white;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-outline:hover {
border-color: var(--primary-light);
color: var(--primary);
background: var(--primary-bg);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 10px 16px;
}
.btn-ghost:hover { color: var(--primary); background: var(--primary-bg); }
.btn-sm { padding: 7px 14px; font-size: 13px; border-radius: 8px; }
.btn-lg { padding: 12px 28px; font-size: 15px; border-radius: 12px; }
/* ===== CARDS ===== */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
margin-bottom: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.card-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
}
.card-title i {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.card-title .icon-upload { background: #dbeafe; color: #2563eb; }
.card-title .icon-person { background: #fce7f3; color: #db2777; }
.card-title .icon-magic { background: #ede9fe; color: #7c3aed; }
.card-title .icon-link { background: #fef3c7; color: #d97706; }
.card-title .icon-export { background: #d1fae5; color: #059669; }
.card-badge {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
/* ===== UPLOAD ZONE ===== */
.upload-zone {
border: 2px dashed var(--border);
border-radius: 14px;
padding: 36px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #fafbfd;
}
.upload-zone:hover {
border-color: var(--primary-light);
background: var(--primary-bg);
}
.upload-zone i.upload-icon {
font-size: 32px;
color: var(--text-muted);
margin-bottom: 10px;
display: block;
}
.upload-zone p { color: var(--text-secondary); font-size: 14px; margin: 3px 0; }
.upload-zone .hint { color: var(--text-muted); font-size: 12px; }
/* ===== FILE LIST ===== */
.file-list { margin-top: 14px; }
.file-item {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 1px solid var(--border-light);
border-radius: 10px;
margin-bottom: 6px;
transition: all 0.2s;
}
.file-item:hover {
background: #fafbfd;
border-color: var(--border);
}
.file-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.file-icon.docx { background: #dbeafe; color: #2563eb; }
.file-icon.person { background: #fce7f3; color: #db2777; }
.file-info { flex: 1; min-width: 0; }
.file-name { font-size: 14px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.file-action-btn {
width: 30px; height: 30px; border-radius: 8px; border: 1px solid transparent;
background: transparent; cursor: pointer; color: var(--text-muted);
display: flex; align-items: center; justify-content: center; font-size: 13px;
transition: all 0.2s;
}
.file-action-btn:hover { border-color: var(--border); background: white; color: var(--text-secondary); }
.file-action-btn.danger:hover { border-color: #fecaca; background: #fef2f2; color: var(--danger); }
/* ===== STATS ROW ===== */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 24px;
}
.stat-item {
background: white;
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px;
display: flex;
align-items: center;
gap: 14px;
}
.stat-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.stat-icon.blue { background: #eef2ff; color: var(--primary); }
.stat-icon.amber { background: #fffbeb; color: var(--accent); }
.stat-icon.green { background: #ecfdf5; color: var(--success); }
.stat-icon.rose { background: #fff1f2; color: #e11d48; }
.stat-value { font-size: 22px; font-weight: 800; color: var(--text); line-height: 1; }
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
/* ===== TWO COL ===== */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
/* ===== TAG CHIPS ===== */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.chip.selected { background: var(--primary-bg); border: 1px solid #c7d2fe; color: var(--primary); }
.chip.unselected { background: white; border: 1px solid var(--border); color: var(--text-secondary); }
.chip.unselected:hover { border-color: var(--primary-light); color: var(--primary); }
/* ===== PROGRESS ===== */
.progress-bar {
height: 8px;
background: var(--border-light);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
transition: width 0.5s ease;
}
.extraction-status {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 12px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.running { background: var(--primary); animation: pulse 1.5s infinite; }
.status-dot.done { background: var(--success); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ===== STORY CARDS ===== */
.story-card {
background: white;
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px 20px;
margin-bottom: 10px;
transition: all 0.2s;
}
.story-card:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
}
.story-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.story-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
margin-top: 6px;
}
.story-tag.pending { background: #fef3c7; color: #92400e; }
.story-tag.matched { background: #d1fae5; color: #065f46; }
.story-summary {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin-top: 8px;
}
.story-source {
font-size: 12px;
color: var(--text-muted);
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
.story-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
/* ===== TABS ===== */
.tabs {
display: flex;
gap: 4px;
background: var(--border-light);
padding: 4px;
border-radius: 10px;
margin-bottom: 16px;
}
.tab {
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
transition: all 0.2s;
border: none;
background: transparent;
}
.tab.active { background: white; color: var(--primary); box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.tab:hover:not(.active) { color: var(--text-secondary); }
/* ===== MATCH PANEL ===== */
.match-panel {
display: grid;
grid-template-columns: 1fr 60px 1fr;
gap: 0;
align-items: start;
}
.match-center-col {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 60px;
}
.match-arrow-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--primary);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
box-shadow: 0 4px 14px rgba(99,102,241,0.3);
transition: all 0.2s;
}
.match-arrow-btn:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(99,102,241,0.4);
}
.match-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
text-align: center;
line-height: 1.4;
}
.person-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border-light);
border-radius: 10px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s;
}
.person-card:hover { border-color: var(--border); background: #fafbfd; }
.person-card.selected { border-color: var(--primary); background: var(--primary-bg); }
.person-avatar {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.person-name { font-size: 14px; font-weight: 600; color: var(--text); }
.person-meta { font-size: 12px; color: var(--text-muted); margin-top: 1px; }
/* ===== EXPORT ===== */
.export-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--border-light);
border-radius: 12px;
margin-bottom: 8px;
transition: all 0.2s;
}
.export-item:hover { border-color: var(--border); background: #fafbfd; }
.export-check {
width: 26px;
height: 26px;
border-radius: 50%;
background: #d1fae5;
color: var(--success);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.export-check.pending { background: var(--border-light); color: var(--text-muted); }
/* ===== PREVIEW DOC ===== */
.preview-doc {
background: white;
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
}
.preview-doc-header {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
padding: 28px 24px;
color: white;
text-align: center;
}
.preview-doc-avatar {
width: 68px;
height: 68px;
border-radius: 50%;
border: 3px solid rgba(255,255,255,0.3);
margin: 0 auto 12px;
background: rgba(255,255,255,0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
}
.preview-doc-name { font-size: 18px; font-weight: 700; }
.preview-doc-info { font-size: 13px; opacity: 0.8; margin-top: 4px; }
.preview-doc-body { padding: 24px; }
.preview-doc-body h3 {
font-size: 15px;
font-weight: 700;
color: var(--text);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary);
display: inline-block;
}
.preview-doc-body p {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
}
/* ===== SETTINGS MODAL ===== */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: white;
border-radius: 18px;
width: 560px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
animation: fadeSlideIn 0.25s ease;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h2 { font-size: 17px; font-weight: 700; margin: 0; }
.modal-close {
width: 32px; height: 32px; border-radius: 8px; border: none;
background: var(--border-light); cursor: pointer; color: var(--text-muted);
display: flex; align-items: center; justify-content: center; font-size: 14px;
transition: all 0.2s;
}
.modal-close:hover { background: #fee2e2; color: var(--danger); }
.modal-body { padding: 24px; }
.form-group { margin-bottom: 16px; }
.form-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
display: block;
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 9px 14px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
color: var(--text);
transition: border-color 0.2s;
}
.form-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ===== SCROLLBAR ===== */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ===== MISC ===== */
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 13px; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.mb-4 { margin-bottom: 16px; }
.flex { display: flex; }
.items-center { align-items: center; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.flex-1 { flex: 1; }
.text-right { text-align: right; }
.match-highlight {
border-color: var(--accent) !important;
background: #fffbeb !important;
}
.scrollable { max-height: 65vh; overflow-y: auto; }
/* ===== WELCOME PAGE ===== */
.welcome-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 240px);
text-align: center;
padding: 40px 20px;
}
.welcome-icon {
width: 88px;
height: 88px;
border-radius: 24px;
background: linear-gradient(135deg, var(--primary), #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
color: white;
margin-bottom: 28px;
box-shadow: 0 12px 40px rgba(99,102,241,0.3);
}
.welcome-title {
font-size: 32px;
font-weight: 800;
color: var(--text);
margin-bottom: 12px;
letter-spacing: -0.5px;
}
.welcome-desc {
font-size: 16px;
color: var(--text-secondary);
line-height: 1.7;
max-width: 480px;
margin-bottom: 36px;
}
.welcome-steps {
display: flex;
gap: 32px;
margin-bottom: 40px;
}
.welcome-step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 120px;
}
.welcome-step-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.welcome-step-icon.s1 { background: #dbeafe; color: #2563eb; }
.welcome-step-icon.s2 { background: #ede9fe; color: #7c3aed; }
.welcome-step-icon.s3 { background: #fef3c7; color: #d97706; }
.welcome-step-icon.s4 { background: #d1fae5; color: #059669; }
.welcome-step-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.welcome-step-num {
font-size: 11px;
color: var(--text-muted);
}
/* ===== TOAST ===== */
.toast-container {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 300;
pointer-events: none;
}
.toast {
background: #1e293b;
color: white;
padding: 12px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.5s forwards;
pointer-events: auto;
}
.toast i { color: var(--accent); font-size: 16px; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-12px); }
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,22 @@
<template>
<header class="app-header">
<div class="app-logo">
<div class="app-logo-icon">
<i class="fas fa-book-open"></i>
</div>
<div class="app-logo-text">
Story Extractor
<span>直播文字稿故事提取工具</span>
</div>
</div>
<div class="header-actions">
<button class="header-btn" title="设置" @click="$emit('open-settings')">
<i class="fas fa-gear"></i>
</button>
</div>
</header>
</template>
<script setup>
defineEmits(['open-settings'])
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="stepper-wrapper">
<div class="stepper">
<div
v-for="(step, index) in steps"
:key="index"
class="stepper-step"
:class="{ active: currentStep === index + 1, completed: currentStep > index + 1 }"
>
<div class="step-circle">
<template v-if="currentStep > index + 1">
<i class="fas fa-check"></i>
</template>
<template v-else>
{{ index + 1 }}
</template>
</div>
<span class="step-label">{{ step }}</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
currentStep: { type: Number, required: true }
})
const steps = ['上传文件', '提取故事', '匹配确认', '预览导出']
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="bottom-nav">
<div class="bottom-nav-hint">
<span>{{ hint }}</span>
</div>
<div class="bottom-nav-actions">
<button
v-if="currentStep === 4"
class="btn btn-success"
:disabled="disabled"
@click="$emit('next')"
>
<i class="fas fa-download"></i>
全部导出
</button>
<button
v-else
class="btn btn-primary"
:disabled="disabled"
@click="$emit('next')"
>
下一步
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</template>
<script setup>
defineProps({
currentStep: { type: Number, required: true },
hint: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
defineEmits(['next'])
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div class="modal-overlay open" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h2><i class="fas fa-cog" style="color:var(--primary); margin-right:8px;"></i>系统设置</h2>
<button class="modal-close" @click="$emit('close')">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<!-- LLM 配置 section -->
<div class="settings-section">
<div class="settings-section-header">
<i class="fas fa-robot"></i>
<span>LLM 配置</span>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">API 提供商</label>
<select v-model="form.llm_provider" class="form-input">
<option value="openai">OpenAI (GPT-4)</option>
<option value="deepseek">DeepSeek</option>
<option value="tongyi">通义千问</option>
<option value="minimax">MiniMax</option>
<option value="custom">自定义兼容 API</option>
</select>
</div>
<div class="form-group">
<label class="form-label">模型名称</label>
<input v-model="form.llm_model" type="text" class="form-input" placeholder="gpt-4o" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">API Base URL</label>
<input v-model="form.llm_base_url" type="text" class="form-input" placeholder="https://api.openai.com/v1" />
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input v-model="form.llm_api_key" type="password" class="form-input" placeholder="输入 API Key" />
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="settings-separator"></div>
<!-- 提取参数 section -->
<div class="settings-section">
<div class="settings-section-header">
<i class="fas fa-sliders-h"></i>
<span>提取参数</span>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">话题分段最大长度行数</label>
<input v-model.number="form.segment_max_lines" type="number" class="form-input" placeholder="500" />
</div>
<div class="form-group">
<label class="form-label">故事最小长度行数</label>
<input v-model.number="form.story_min_lines" type="number" class="form-input" placeholder="50" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">置信度阈值</label>
<input v-model.number="form.confidence_threshold" type="number" class="form-input" step="0.1" min="0" max="1" placeholder="0.6" />
</div>
<div class="form-group">
<label class="form-label">Temperature</label>
<input v-model.number="form.temperature" type="number" class="form-input" step="0.1" min="0" max="1" placeholder="0.3" />
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="$emit('close')">取消</button>
<button class="btn btn-outline" :disabled="testing" @click="handleTest">
<i v-if="testing" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-plug"></i>
{{ testing ? '测试中...' : '测试连接' }}
</button>
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
<i v-if="saving" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-save"></i>
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getSettings, updateSettings, testLlm } from '../api'
import { useToast } from '../composables/useToast'
const { showToast } = useToast()
const emit = defineEmits(['close'])
const saving = ref(false)
const testing = ref(false)
const defaultForm = {
llm_provider: 'openai',
llm_model: '',
llm_base_url: '',
llm_api_key: '',
segment_max_lines: 500,
story_min_lines: 50,
confidence_threshold: 0.6,
temperature: 0.3,
}
const form = ref({ ...defaultForm })
onMounted(async () => {
try {
const { data } = await getSettings()
form.value = {
llm_provider: data.llm_provider || defaultForm.llm_provider,
llm_model: data.llm_model || '',
llm_base_url: data.llm_base_url || '',
llm_api_key: data.llm_api_key || '',
segment_max_lines: data.segment_max_lines ?? defaultForm.segment_max_lines,
story_min_lines: data.story_min_lines ?? defaultForm.story_min_lines,
confidence_threshold: data.confidence_threshold ?? defaultForm.confidence_threshold,
temperature: data.temperature ?? defaultForm.temperature,
}
} catch (e) {
showToast('获取设置失败: ' + (e.response?.data?.detail || e.message), 'error')
}
})
async function handleSave() {
saving.value = true
try {
await updateSettings(form.value)
showToast('设置已保存', 'success')
emit('close')
} catch (e) {
showToast('保存失败: ' + (e.response?.data?.detail || e.message), 'error')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
const res = await testLlm()
if (res.data && res.data.success) {
showToast('连接测试成功: ' + (res.data.message || ''), 'success')
} else {
showToast('连接测试失败: ' + (res.data?.message || '未知错误'), 'error')
}
} catch (e) {
showToast('连接测试失败: ' + (e.response?.data?.detail || e.message), 'error')
} finally {
testing.value = false
}
}
</script>
<style scoped>
.settings-section {
margin-bottom: 4px;
}
.settings-section-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
}
.settings-section-header i {
color: var(--primary);
font-size: 15px;
}
.settings-separator {
height: 1px;
background: var(--border);
margin: 20px 0;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="toast-container">
<div v-for="t in toasts" :key="t.id" class="toast" :class="'toast-' + t.type">
<i :class="iconClass(t.type)"></i>
{{ t.message }}
</div>
</div>
</template>
<script setup>
import { useToast } from '../composables/useToast'
const { toasts } = useToast()
function iconClass(type) {
switch (type) {
case 'success':
return 'fas fa-check-circle'
case 'error':
return 'fas fa-times-circle'
default:
return 'fas fa-triangle-exclamation'
}
}
</script>
<style scoped>
.toast-warning i { color: var(--accent); }
.toast-success i { color: var(--success); }
.toast-error i { color: var(--danger); }
</style>

View File

@@ -0,0 +1,15 @@
import { ref } from 'vue'
const toasts = ref([])
let id = 0
export function useToast() {
function showToast(message, type = 'warning') {
const t = { id: ++id, message, type }
toasts.value.push(t)
setTimeout(() => {
toasts.value = toasts.value.filter(x => x.id !== t.id)
}, 3000)
}
return { toasts, showToast }
}

8
frontend/src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/styles/global.css'
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'welcome', component: () => import('../views/WelcomeView.vue') },
{ path: '/upload', name: 'upload', component: () => import('../views/UploadView.vue') },
{ path: '/extract', name: 'extract', component: () => import('../views/ExtractView.vue') },
{ path: '/match', name: 'match', component: () => import('../views/MatchView.vue') },
{ path: '/export', name: 'export', component: () => import('../views/ExportView.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

View File

@@ -0,0 +1,316 @@
<template>
<div class="step-page active">
<div class="two-col">
<!-- Left column: Export list -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i class="fas fa-file-export icon-export"></i>
导出列表
</div>
<span class="card-badge" v-if="exportList.length">
{{ exportList.length }}
</span>
</div>
<div v-if="loading" class="text-muted text-sm" style="text-align: center; padding: 32px 0;">
<i class="fas fa-spinner fa-spin"></i> 加载中...
</div>
<div v-else-if="!exportList.length" class="text-muted text-sm" style="text-align: center; padding: 32px 0;">
暂无导出数据请先完成故事匹配
</div>
<div v-else class="scrollable">
<div
v-for="item in exportList"
:key="item.person_id"
class="export-item"
:class="{ active: selectedPersonId === item.person_id }"
>
<div class="export-check" :class="{ pending: !item.story_count }">
<i :class="item.story_count ? 'fas fa-check' : 'fas fa-minus'"></i>
</div>
<div class="export-item-info" style="flex: 1; min-width: 0;">
<div class="export-item-name">{{ item.person_name }}</div>
<div class="export-item-meta">
{{ item.story_count }} 个故事
<span v-if="item.has_photo" style="color: var(--success);"> · 有照片</span>
<span v-else style="color: var(--text-muted);"> · 无照片</span>
</div>
</div>
<div class="export-item-actions" style="display: flex; gap: 6px; flex-shrink: 0;">
<button
class="file-action-btn"
title="预览"
:disabled="!item.story_count"
@click="handlePreview(item.person_id)"
>
<i class="fas fa-eye"></i>
</button>
<button
class="file-action-btn"
title="下载"
:disabled="!item.story_count"
@click="handleDownload(item.person_id, item.person_name)"
>
<i class="fas fa-download"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Right column: Preview -->
<div class="card" style="margin-bottom: 0;">
<div class="card-header">
<div class="card-title">
<i class="fas fa-eye icon-export"></i>
文档预览
</div>
<div v-if="previewData" style="display: flex; gap: 6px;">
<button
class="btn btn-ghost btn-sm"
:disabled="!hasPrev"
@click="navigatePreview(-1)"
>
<i class="fas fa-chevron-left"></i>
</button>
<button
class="btn btn-ghost btn-sm"
:disabled="!hasNext"
@click="navigatePreview(1)"
>
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<div v-if="previewLoading" class="text-muted text-sm" style="text-align: center; padding: 48px 0;">
<i class="fas fa-spinner fa-spin"></i> 加载预览中...
</div>
<div v-else-if="!previewData" class="text-muted text-sm" style="text-align: center; padding: 48px 0;">
<i class="fas fa-file-alt" style="font-size: 28px; display: block; margin-bottom: 12px; color: var(--border);"></i>
选择一位讲述者以预览其文档
</div>
<div v-else class="preview-doc scrollable">
<div class="preview-doc-header">
<div class="preview-doc-avatar">
<i class="fas fa-user"></i>
</div>
<div class="preview-doc-name">{{ previewData.person_name }}</div>
<div class="preview-doc-info">{{ previewData.person_info || '暂无个人信息' }}</div>
</div>
<div class="preview-doc-body">
<div v-for="(story, idx) in previewData.stories" :key="idx" style="margin-bottom: 24px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h3 style="font-size: 16px; font-weight: 700;">{{ story.title }}</h3>
<button
v-if="story.raw_material"
class="btn btn-ghost btn-sm"
style="font-size: 12px; color: var(--primary);"
@click="toggleMaterial(idx)"
>
<i :class="showMaterialIdx === idx ? 'fas fa-eye-slash' : 'fas fa-database'"></i>
{{ showMaterialIdx === idx ? '收起素材' : '查看素材' }}
</button>
</div>
<!-- 素材 JSON 展示 -->
<div v-if="showMaterialIdx === idx && story.raw_material" style="margin-bottom: 12px;">
<div style="background: #1e1e2e; border-radius: 8px; padding: 12px; max-height: 400px; overflow: auto;">
<pre style="margin: 0; font-size: 12px; line-height: 1.5; color: #cdd6f4; white-space: pre-wrap; word-break: break-all;">{{ formatMaterial(story.raw_material) }}</pre>
</div>
</div>
<!-- 故事梗概 -->
<div v-if="story.summary" style="margin-bottom: 8px;">
<span style="font-weight: 600; color: #335599; font-size: 13px;">故事梗概</span>
<p style="margin: 4px 0 8px; text-indent: 2em; line-height: 1.6; color: var(--text-secondary);">{{ story.summary }}</p>
</div>
<!-- 关键词标签 -->
<div v-if="story.tags && story.tags.length" style="margin-bottom: 12px;">
<span style="font-weight: 600; color: #335599; font-size: 13px;">关键词标签</span>
<p style="margin: 4px 0 8px; color: var(--text-muted); font-size: 13px;">{{ story.tags.join('、') }}</p>
</div>
<!-- 故事内容Markdown 渲染 -->
<div v-if="story.content" class="story-content-preview" v-html="renderMarkdown(story.content)"></div>
</div>
<div v-if="!previewData.stories || !previewData.stories.length" class="text-muted text-sm">
暂无故事内容
</div>
</div>
</div>
</div>
</div>
<!-- Restart Button -->
<div style="text-align: center; margin-top: 20px;">
<button class="btn btn-outline" @click="$emit('restart')">
<i class="fas fa-rotate-right"></i>
再来一次
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getExportList, previewExport, downloadOne, downloadAll } from '../api'
// Markdown 渲染(简易版,支持 ## 标题、> 引用、- 列表)
function renderMarkdown(text) {
if (!text) return ''
return text
.split('\n')
.map(line => {
const trimmed = line.trim()
if (!trimmed) return '<br>'
if (trimmed.startsWith('## ')) return `<h4 style="font-size:14px;font-weight:700;margin:12px 0 6px;">${trimmed.slice(3)}</h4>`
if (trimmed.startsWith('### ')) return `<h5 style="font-size:13px;font-weight:600;margin:8px 0 4px;">${trimmed.slice(4)}</h5>`
if (trimmed.startsWith('> ')) return `<blockquote style="border-left:3px solid #ddd;margin:6px 0;padding:4px 12px;color:#666;font-style:italic;">${trimmed.slice(2)}</blockquote>`
if (trimmed.startsWith('- ')) return `<div style="padding-left:16px;margin:2px 0;">• ${trimmed.slice(2)}</div>`
return `<p style="text-indent:2em;line-height:1.8;margin:4px 0;">${trimmed}</p>`
})
.join('')
}
import { useToast } from '../composables/useToast'
const emit = defineEmits(['next', 'restart'])
const { showToast } = useToast()
const exportList = ref([])
const loading = ref(false)
const selectedPersonId = ref(null)
const previewData = ref(null)
const previewLoading = ref(false)
const showMaterialIdx = ref(null)
function toggleMaterial(idx) {
showMaterialIdx.value = showMaterialIdx.value === idx ? null : idx
}
function formatMaterial(raw) {
try {
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw
return JSON.stringify(obj, null, 2)
} catch {
return raw
}
}
function triggerDownload(blob, filename) {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
async function fetchExportList() {
loading.value = true
try {
const res = await getExportList()
exportList.value = res.data || []
} catch (e) {
showToast('获取导出列表失败: ' + (e.response?.data?.detail || e.message), 'danger')
} finally {
loading.value = false
}
}
async function handlePreview(personId) {
selectedPersonId.value = personId
previewLoading.value = true
previewData.value = null
try {
const res = await previewExport(personId)
previewData.value = res.data
} catch (e) {
showToast('加载预览失败: ' + (e.response?.data?.detail || e.message), 'danger')
} finally {
previewLoading.value = false
}
}
async function handleDownload(personId, personName) {
try {
const res = await downloadOne(personId)
triggerDownload(res.data, `${personName}.docx`)
showToast(`${personName} 的文档已开始下载`, 'success')
} catch (e) {
showToast('下载失败: ' + (e.response?.data?.detail || e.message), 'danger')
}
}
const selectedIndex = computed(() => {
if (!selectedPersonId.value) return -1
return exportList.value.findIndex(item => item.person_id === selectedPersonId.value)
})
const hasPrev = computed(() => selectedIndex.value > 0)
const hasNext = computed(() => selectedIndex.value >= 0 && selectedIndex.value < exportList.value.length - 1)
function navigatePreview(direction) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < exportList.value.length) {
const item = exportList.value[newIndex]
if (item.story_count) {
handlePreview(item.person_id)
}
}
}
async function handleDownloadAll() {
try {
const res = await downloadAll()
triggerDownload(res.data, '全部故事文档.zip')
showToast('全部文档已开始下载', 'success')
} catch (e) {
showToast('批量下载失败: ' + (e.response?.data?.detail || e.message), 'danger')
}
}
// Expose handleDownloadAll so the parent can call it via the BottomNav "全部导出" button
defineExpose({ handleDownloadAll })
onMounted(() => {
fetchExportList()
})
</script>
<style scoped>
.export-item.active {
border-color: var(--primary);
background: var(--primary-bg);
}
.export-item-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.export-item-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.file-action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.file-action-btn:disabled:hover {
border-color: transparent;
background: transparent;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,410 @@
<template>
<div class="step-page active">
<!-- Progress Card -->
<div class="card" v-if="!isCompleted && !hasError">
<div class="card-header">
<div class="card-title">
<i class="fas fa-brain" style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;background:#ede9fe;color:#7c3aed;"></i>
AI 正在工作中
</div>
<span class="card-badge" :style="{ fontSize: '18px', fontWeight: '800', color: isCompleted ? 'var(--success)' : 'var(--primary)' }">
{{ progress }}%
</span>
</div>
<!-- Progress Bar -->
<div class="progress-bar" style="height: 10px; border-radius: 5px;">
<div class="progress-fill" :style="{ width: progress + '%', transition: 'width 1s ease' }"></div>
</div>
<!-- Current File Info -->
<div style="margin-top: 20px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 14px;">
<div class="status-dot" :class="isCompleted ? 'done' : 'running'"></div>
<div style="flex: 1;">
<div style="font-size: 14px; font-weight: 600; color: var(--text);">{{ displayFile }}</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">{{ displayAction }}</div>
</div>
</div>
<!-- Phase Progress -->
<div v-if="phase" style="margin-bottom: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 12px; font-weight: 600; color: var(--text);">{{ phaseLabel }}</span>
<span style="font-size: 12px; color: var(--text-muted);">{{ phasePercent }}%</span>
</div>
<div class="progress-bar" style="height: 4px; border-radius: 2px;">
<div class="progress-fill" :style="{ width: phasePercent + '%', transition: 'width 0.5s ease', background: 'var(--primary)' }"></div>
</div>
<div v-if="phaseDetail" style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">{{ phaseDetail }}</div>
</div>
<!-- Stats Grid -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 16px;">
<div style="background: var(--border-light); border-radius: 10px; padding: 12px 14px; text-align: center;">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">文件进度</div>
<div style="font-size: 13px; font-weight: 700; color: var(--text);">{{ filesCompleted }} / {{ totalFiles || '...' }}</div>
</div>
<div style="background: var(--border-light); border-radius: 10px; padding: 12px 14px; text-align: center;">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">当前阶段</div>
<div style="font-size: 13px; font-weight: 700;" :style="{ color: phase ? 'var(--primary)' : 'var(--text)' }">
{{ phaseLabelShort || '等待中' }}
</div>
</div>
<div style="background: var(--border-light); border-radius: 10px; padding: 12px 14px; text-align: center;">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">故事发现</div>
<div style="font-size: 13px; font-weight: 700;" :style="{ color: storiesFound > 0 ? 'var(--success)' : 'var(--text)' }">
{{ storiesFound }}
</div>
</div>
</div>
</div>
</div>
<!-- Error Card -->
<div class="card" v-if="hasError">
<div class="card-header">
<div class="card-title">
<i class="fas fa-circle-exclamation" style="width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;background:#fee2e2;color:#ef4444;"></i>
无法提取故事
</div>
</div>
<p class="text-sm" style="color: var(--danger); margin-bottom: 16px;">{{ errorMessage }}</p>
<p class="text-sm" style="color: var(--text-muted); margin-bottom: 16px;">
请检查上传的文件是否包含学员与老师的对话记录而非纯课程内容
</p>
<div style="display: flex; gap: 12px;">
<button class="btn btn-outline" @click="$emit('go-to', 'upload')">
<i class="fas fa-arrow-left"></i>
返回重选文件
</button>
<button class="btn btn-primary" @click="retryExtraction">
<i class="fas fa-rotate-right"></i>
重新提取
</button>
</div>
</div>
<!-- Results Card -->
<div class="card" v-if="isCompleted">
<div class="card-header">
<div class="card-title">
<i class="icon-magic"><i class="fas fa-list"></i></i>
已提取的故事
</div>
<span class="card-badge"> {{ stories.length }} 个故事</span>
</div>
<div v-if="stories.length === 0" style="text-align: center; padding: 32px 0;">
<i class="fas fa-inbox" style="font-size: 28px; display: block; margin-bottom: 12px; color: var(--border);"></i>
<p class="text-muted text-sm" style="margin-bottom: 16px;">未发现任何故事</p>
<p class="text-muted text-sm" style="margin-bottom: 16px;">
可能原因文件中没有学员的深度对话 AI 服务在提取过程中出错
</p>
<div style="display: flex; gap: 12px; justify-content: center;">
<button class="btn btn-outline" @click="$emit('go-to', 'upload')">
<i class="fas fa-arrow-left"></i>
返回重选文件
</button>
<button class="btn btn-primary" @click="retryExtraction">
<i class="fas fa-rotate-right"></i>
重新提取
</button>
</div>
</div>
<div v-for="story in stories" :key="story.id" class="story-card">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1; min-width: 0;">
<div class="story-title">{{ story.title }}</div>
<div class="story-tag" :class="story.match_status === 'matched' ? 'matched' : 'pending'">
<i class="fas" :class="story.match_status === 'matched' ? 'fa-check' : 'fa-clock'"></i>
{{ story.match_status === 'matched' ? '已匹配' : '待匹配' }}
</div>
</div>
<div class="story-actions">
<button class="btn btn-outline btn-sm" @click="viewStory(story)" title="查看">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-ghost btn-sm" style="color: var(--danger);" @click="handleDeleteStory(story)" title="删除">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="story-summary">{{ story.summary }}</div>
<div class="story-source">
<i class="fas fa-file-alt"></i>
{{ formatSource(story) }}
</div>
</div>
</div>
<!-- Story Detail Modal -->
<div v-if="viewingStory" class="modal-overlay" @click.self="viewingStory = null">
<div class="card" style="max-width: 640px; width: 90%; max-height: 80vh; overflow-y: auto; position: relative;">
<div class="card-header">
<div class="card-title">{{ viewingStory.title }}</div>
<button class="btn btn-ghost btn-sm" @click="viewingStory = null" style="font-size: 18px; color: var(--text-muted);">&times;</button>
</div>
<div style="margin-bottom: 12px;">
<span v-for="tag in (viewingStory.tags_json || [])" :key="tag" style="display: inline-block; background: var(--border-light); border-radius: 12px; padding: 2px 10px; font-size: 12px; color: var(--text-muted); margin-right: 6px; margin-bottom: 4px;">{{ tag }}</span>
</div>
<div style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">{{ viewingStory.summary }}</div>
<div v-html="formattedContent" style="font-size: 14px; line-height: 1.7; color: var(--text);"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, inject, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { startExtraction, streamExtractionStatus, getStories, deleteStory, getExtractionStatus } from '../api'
import { useToast } from '../composables/useToast'
const router = useRouter()
const { showToast } = useToast()
const setExtracting = inject('setExtracting', () => {})
defineEmits(['next', 'prev', 'go-to'])
const taskId = ref(null)
// Progress state
const progress = ref(0)
const currentFile = ref('')
const currentAction = ref('')
const filesCompleted = ref(0)
const totalFiles = ref(0)
const storiesFound = ref(0)
const phase = ref('')
const phasePercent = ref(0)
const phaseDetail = ref('')
// Results state
const isCompleted = ref(false)
const hasError = ref(false)
const errorMessage = ref('')
const stories = ref([])
const viewingStory = ref(null)
const formattedContent = computed(() => {
if (!viewingStory.value) return ''
return viewingStory.value.content || ''
})
let eventSource = null
const phaseLabels = {
preprocess: '预处理分析',
identify: '识别故事',
chunk: '切片提取',
merge: '素材重组',
generate: '生成故事',
}
const phaseLabel = computed(() => phaseLabels[phase.value] || '')
const phaseLabelShort = computed(() => phaseLabels[phase.value] || '')
const displayFile = computed(() => {
if (isCompleted.value) return '全部处理完成!'
if (currentFile.value) return '正在处理:' + currentFile.value
return '准备中...'
})
const displayAction = computed(() => {
if (isCompleted.value) return `共处理 ${totalFiles.value} 份文字稿,发现 ${storiesFound.value} 个故事`
if (currentAction.value) return currentAction.value
return '初始化...'
})
function connectSSE() {
if (eventSource) {
eventSource.close()
}
eventSource = streamExtractionStatus(taskId.value)
eventSource.addEventListener('progress', (event) => {
try {
const data = JSON.parse(event.data)
currentFile.value = data.current_file || ''
currentAction.value = data.action || ''
filesCompleted.value = data.files_completed || 0
totalFiles.value = data.files_total || 0
storiesFound.value = data.stories_found || 0
progress.value = Math.round(data.percent || 0)
phase.value = data.phase || ''
phasePercent.value = Math.round(data.phase_percent || 0)
phaseDetail.value = data.phase_detail || ''
if (data.status === 'completed') {
isCompleted.value = true
progress.value = 100
phasePercent.value = 100
setExtracting(false)
closeSSE()
loadStories()
} else if (data.status === 'error' || data.error) {
hasError.value = true
errorMessage.value = data.error || data.action || '提取过程中发生未知错误'
setExtracting(false)
closeSSE()
} else if (data.status === 'stopped') {
hasError.value = true
errorMessage.value = '提取已停止'
setExtracting(false)
closeSSE()
}
} catch (e) {
console.error('Failed to parse SSE data:', e)
}
})
eventSource.onerror = () => {
console.warn('SSE connection error')
}
}
function closeSSE() {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
async function loadStories() {
try {
const res = await getStories()
stories.value = res.data || []
} catch (e) {
console.error('Failed to load stories:', e)
showToast('加载故事列表失败', 'warning')
}
}
async function handleDeleteStory(story) {
try {
await deleteStory(story.id)
stories.value = stories.value.filter(s => s.id !== story.id)
storiesFound.value = Math.max(0, storiesFound.value - 1)
showToast('故事已删除', 'warning')
} catch (e) {
console.error('Failed to delete story:', e)
showToast('删除故事失败', 'warning')
}
}
function viewStory(story) {
// 解析 tags JSON
try {
story.tags_json = typeof story.tags === 'string' ? JSON.parse(story.tags) : (story.tags || [])
} catch {
story.tags_json = []
}
viewingStory.value = story
}
async function retryExtraction() {
hasError.value = false
errorMessage.value = ''
isCompleted.value = false
progress.value = 0
currentFile.value = ''
currentAction.value = ''
filesCompleted.value = 0
totalFiles.value = 0
storiesFound.value = 0
phase.value = ''
phasePercent.value = 0
phaseDetail.value = ''
stories.value = []
await initiateExtraction()
}
async function initiateExtraction() {
setExtracting(true)
try {
const res = await startExtraction(taskId.value)
if (res.data?.task_id) {
taskId.value = res.data.task_id
localStorage.setItem('extraction_task_id', res.data.task_id)
}
connectSSE()
} catch (e) {
if (e.response && e.response.status === 400) {
showToast('提取任务正在进行中', 'warning')
connectSSE()
} else {
hasError.value = true
errorMessage.value = e.response?.data?.detail || '无法启动提取任务'
setExtracting(false)
showToast('启动提取失败', 'warning')
}
}
}
function formatSource(story) {
const parts = []
if (story.source_lines) {
try {
const lines = typeof story.source_lines === 'string' ? JSON.parse(story.source_lines) : story.source_lines
if (Array.isArray(lines) && lines.length > 0) {
const min = Math.min(...lines)
const max = Math.max(...lines)
parts.push(`${min.toLocaleString()}-${max.toLocaleString()}`)
}
} catch (e) {
// ignore
}
}
if (story.duration_minutes) {
parts.push(`${Math.round(story.duration_minutes)} 分钟`)
}
return parts.join(' · ') || '未知来源'
}
onMounted(async () => {
const savedTaskId = localStorage.getItem('extraction_task_id')
if (savedTaskId) {
taskId.value = savedTaskId
try {
const { data } = await getExtractionStatus(savedTaskId)
if (data.status === 'completed') {
isCompleted.value = true
progress.value = 100
await loadStories()
return
}
if (data.status === 'running') {
setExtracting(true)
connectSSE()
return
}
} catch (e) {
// 任务不存在,清除并重新开始
}
}
await initiateExtraction()
})
onUnmounted(() => {
closeSSE()
setExtracting(false)
})
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<div class="step-page active">
<!-- Header Card -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i class="fas fa-link icon-link"></i>
故事匹配
</div>
<div class="card-badge" v-if="stories.length">
{{ matchedStories.length }} / {{ stories.length }} 已匹配
</div>
</div>
<p class="text-muted text-sm">将提取的故事与讲述者进行一一对应选择左侧故事选择右侧讲述者点击中间箭头完成匹配</p>
</div>
<!-- Match Panel: 3 columns -->
<div class="match-panel">
<!-- Left Column: Pending Stories -->
<div class="card" style="margin-bottom: 0;">
<div class="card-header">
<div class="card-title">
<i class="fas fa-book icon-magic"></i>
待匹配故事
</div>
<div class="card-badge" v-if="pendingStories.length">
{{ pendingStories.length }}
</div>
</div>
<div class="scrollable">
<template v-if="pendingStories.length">
<div
v-for="story in pendingStories"
:key="story.id"
class="story-card"
:class="{ 'match-highlight': selectedStory?.id === story.id }"
@click="selectStory(story)"
>
<div class="story-title">{{ story.title }}</div>
<div class="story-tag pending">
<i class="fas fa-clock"></i> 待匹配
</div>
<div class="story-summary" v-if="story.summary">
{{ truncateText(story.summary, 80) }}
</div>
<div class="story-source">
<i class="fas fa-file-lines"></i>
{{ story.speaker_nickname || '未知讲述者' }}
<span v-if="story.duration_minutes"> · {{ story.duration_minutes }}分钟</span>
</div>
</div>
</template>
<p v-else class="text-muted text-sm" style="text-align: center; padding: 24px 0;">
<i class="fas fa-check-circle" style="color: var(--success); margin-right: 4px;"></i>
所有故事均已匹配完成
</p>
</div>
</div>
<!-- Center Column: Arrow Button -->
<div class="match-center-col">
<button
class="match-arrow-btn"
title="匹配"
:disabled="!selectedStory || !selectedPerson || matching"
@click="handleMatch"
>
<i class="fas fa-arrow-right" v-if="!matching"></i>
<i class="fas fa-spinner fa-spin" v-else></i>
</button>
<span class="match-hint">
<template v-if="!selectedStory && !selectedPerson">选择故事后<br>点击匹配</template>
<template v-else-if="!selectedPerson">请选择<br>讲述者</template>
<template v-else-if="!selectedStory">请选择<br>故事</template>
<template v-else-if="matching">匹配中...</template>
<template v-else>点击匹配</template>
</span>
</div>
<!-- Right Column: Persons -->
<div class="card" style="margin-bottom: 0;">
<div class="card-header">
<div class="card-title">
<i class="fas fa-user-group icon-person"></i>
讲述者
</div>
<div class="card-badge">{{ persons.length + 1 }} </div>
</div>
<div class="scrollable">
<!-- 默认暂无选项始终置顶 -->
<div
class="person-card"
:class="{ selected: selectedPerson?.id === 'unknown' }"
@click="selectPerson({ id: 'unknown', name: '暂无' })"
>
<div class="person-avatar" style="background: linear-gradient(135deg, #f1f5f9, #e2e8f0); color: var(--text-muted);">
<i class="fas fa-question" style="font-size: 14px;"></i>
</div>
<div>
<div class="person-name">暂无</div>
<div class="person-meta">{{ getUnknownMatchCount() }}</div>
</div>
</div>
<!-- 真实讲述者列表 -->
<template v-if="persons.length">
<div
v-for="person in persons"
:key="person.id"
class="person-card"
:class="{ selected: selectedPerson?.id === person.id }"
@click="selectPerson(person)"
>
<div class="person-avatar">{{ person.name?.charAt(0) || '?' }}</div>
<div>
<div class="person-name">{{ person.name }}</div>
<div class="person-meta">{{ getPersonMatchCount(person.id) }}</div>
</div>
</div>
</template>
<p v-else class="text-muted text-sm" style="text-align: center; padding: 16px 0;">
暂无讲述者信息
</p>
</div>
</div>
</div>
<!-- Matched Stories Section -->
<div class="card mt-4" v-if="matchedStories.length">
<div class="card-header">
<div class="card-title">
<i class="fas fa-check-double icon-export"></i>
已匹配故事
</div>
<div class="card-badge">{{ matchedStories.length }} </div>
</div>
<div class="scrollable" style="max-height: 40vh;">
<div
v-for="story in matchedStories"
:key="story.id"
class="story-card"
>
<div class="story-title">{{ story.title }}</div>
<div class="story-tag matched">
<i class="fas fa-check"></i> 已匹配
</div>
<div class="story-summary" v-if="story.summary">
{{ truncateText(story.summary, 80) }}
</div>
<div class="story-source">
<i class="fas fa-user"></i>
{{ getPersonName(story.person_id) }}
</div>
<div class="story-actions">
<button
class="btn btn-sm btn-outline"
style="color: var(--danger); border-color: #fecaca;"
@click="handleUnmatch(story)"
:disabled="unmatchingId === story.id"
>
<i class="fas fa-link-slash" v-if="unmatchingId !== story.id"></i>
<i class="fas fa-spinner fa-spin" v-else></i>
取消匹配
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getStories, getPersons, createMatch, removeMatch } from '../api'
import { useToast } from '../composables/useToast'
const emit = defineEmits(['next', 'prev', 'go-to'])
const { showToast } = useToast()
// Data
const stories = ref([])
const persons = ref([])
const selectedStory = ref(null)
const selectedPerson = ref(null)
const matching = ref(false)
const unmatchingId = ref(null)
const loading = ref(false)
// Computed
const pendingStories = computed(() =>
stories.value.filter(s => s.match_status === 'pending')
)
const matchedStories = computed(() =>
stories.value.filter(s => s.match_status === 'matched')
)
// Methods
function truncateText(text, maxLen) {
if (!text) return ''
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text
}
function getPersonName(personId) {
if (personId === 'unknown') return '暂无'
const person = persons.value.find(p => p.id === personId)
return person?.name || '未知讲述者'
}
function getPersonMatchCount(personId) {
const count = stories.value.filter(s => s.person_id === personId && s.match_status === 'matched').length
return count > 0 ? `已匹配 ${count} 个故事` : '未匹配'
}
function getUnknownMatchCount() {
const count = stories.value.filter(s => s.person_id === 'unknown' && s.match_status === 'matched').length
return count > 0 ? `已匹配 ${count} 个故事` : '未知讲述者可用'
}
function selectStory(story) {
selectedStory.value = selectedStory.value?.id === story.id ? null : story
}
function selectPerson(person) {
selectedPerson.value = selectedPerson.value?.id === person.id ? null : person
}
async function fetchAllData() {
loading.value = true
try {
const [storiesRes, personsRes] = await Promise.all([
getStories('all'),
getPersons()
])
stories.value = storiesRes.data || []
persons.value = personsRes.data || []
} catch (err) {
console.error('Failed to fetch data:', err)
showToast('加载数据失败,请稍后重试', 'error')
} finally {
loading.value = false
}
}
async function handleMatch() {
if (!selectedStory.value || !selectedPerson.value) {
showToast('请同时选择一个故事和一个讲述者', 'warning')
return
}
if (matching.value) return
matching.value = true
try {
await createMatch(selectedStory.value.id, selectedPerson.value.id)
showToast(`已将「${selectedStory.value.title}」匹配给「${selectedPerson.value.name}`, 'success')
selectedStory.value = null
selectedPerson.value = null
await fetchAllData()
} catch (err) {
console.error('Match failed:', err)
const msg = err.response?.data?.detail || '匹配失败,请稍后重试'
showToast(msg, 'error')
} finally {
matching.value = false
}
}
async function handleUnmatch(story) {
if (unmatchingId.value) return
unmatchingId.value = story.id
try {
await removeMatch(story.id)
showToast(`已取消「${story.title}」的匹配`, 'success')
await fetchAllData()
} catch (err) {
console.error('Unmatch failed:', err)
const msg = err.response?.data?.detail || '取消匹配失败,请稍后重试'
showToast(msg, 'error')
} finally {
unmatchingId.value = null
}
}
onMounted(() => {
fetchAllData()
})
</script>

View File

@@ -0,0 +1,292 @@
<template>
<div class="step-page active">
<div class="two-col">
<!-- Left Column: Transcripts -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i class="icon-upload"><i class="fas fa-file-alt"></i></i>
直播文字稿
</div>
<span class="card-badge">DOCX 格式</span>
</div>
<div
class="upload-zone"
:class="{ 'is-dragging': transcriptDragging }"
@click="triggerTranscriptInput"
@dragover.prevent="transcriptDragging = true"
@dragleave.prevent="transcriptDragging = false"
@drop.prevent="handleTranscriptDrop"
>
<i class="fas fa-cloud-upload-alt upload-icon"></i>
<p>拖拽文件到此处或点击上传</p>
<p class="hint">支持批量上传 · 仅限 .docx 格式</p>
</div>
<input
ref="transcriptInputRef"
type="file"
accept=".docx"
multiple
style="display: none"
@change="handleTranscriptSelect"
/>
<div class="file-list">
<div v-if="transcriptUploading" class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: transcriptProgress + '%' }"></div>
</div>
<p class="text-sm text-muted mt-2">正在上传...</p>
</div>
<div
v-for="item in transcripts"
:key="item.id"
class="file-item"
>
<div class="file-icon docx">
<i class="fas fa-file-word"></i>
</div>
<div class="file-info">
<div class="file-name">{{ item.filename }}</div>
<div class="file-meta">
{{ formatLineCount(item.line_count) }} · {{ formatFileSize(item.file_size) }}
</div>
</div>
<button
class="file-action-btn danger"
title="删除"
@click="handleDeleteTranscript(item.id)"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
<!-- Right Column: Persons -->
<div class="card">
<div class="card-header">
<div class="card-title">
<i class="icon-person"><i class="fas fa-user"></i></i>
讲述者信息
</div>
<span class="card-badge">一人一个 DOCX</span>
</div>
<div
class="upload-zone"
:class="{ 'is-dragging': personDragging }"
@click="triggerPersonInput"
@dragover.prevent="personDragging = true"
@dragleave.prevent="personDragging = false"
@drop.prevent="handlePersonDrop"
>
<i class="fas fa-user-plus upload-icon"></i>
<p>拖拽文件到此处或点击上传</p>
<p class="hint">每个讲述者一个 DOCX · 包含个人信息和照片</p>
</div>
<input
ref="personInputRef"
type="file"
accept=".docx"
multiple
style="display: none"
@change="handlePersonSelect"
/>
<div class="file-list">
<div v-if="personUploading" class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: personProgress + '%' }"></div>
</div>
<p class="text-sm text-muted mt-2">正在上传...</p>
</div>
<div
v-for="item in persons"
:key="item.id"
class="file-item"
>
<div class="file-icon person">
<i class="fas fa-user"></i>
</div>
<div class="file-info">
<div class="file-name">{{ item.filename }}</div>
<div class="file-meta">
{{ item.has_photo ? '含照片' : '无照片' }} · {{ formatFileSize(item.file_size) }}
</div>
</div>
<button
class="file-action-btn danger"
title="删除"
@click="handleDeletePerson(item.id)"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
uploadTranscripts,
uploadPersons,
getTranscripts,
getPersons,
deleteTranscript,
deletePerson,
} from '../api'
import { useToast } from '../composables/useToast'
defineEmits(['next', 'prev', 'go-to'])
const { showToast } = useToast()
// ===== Transcript state =====
const transcripts = ref([])
const transcriptDragging = ref(false)
const transcriptUploading = ref(false)
const transcriptProgress = ref(0)
const transcriptInputRef = ref(null)
// ===== Person state =====
const persons = ref([])
const personDragging = ref(false)
const personUploading = ref(false)
const personProgress = ref(0)
const personInputRef = ref(null)
// ===== Format helpers =====
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatLineCount(count) {
if (count == null) return '-'
return count.toLocaleString('en-US')
}
// ===== Transcript upload =====
function triggerTranscriptInput() {
transcriptInputRef.value?.click()
}
function handleTranscriptSelect(e) {
const files = Array.from(e.target.files || [])
if (files.length > 0) uploadTranscriptFiles(files)
e.target.value = ''
}
function handleTranscriptDrop(e) {
transcriptDragging.value = false
const files = Array.from(e.dataTransfer.files || []).filter(f =>
f.name.toLowerCase().endsWith('.docx')
)
if (files.length > 0) {
uploadTranscriptFiles(files)
} else {
showToast('请上传 .docx 格式的文件', 'error')
}
}
async function uploadTranscriptFiles(files) {
transcriptUploading.value = true
transcriptProgress.value = 0
try {
await uploadTranscripts(files)
transcriptProgress.value = 100
const res = await getTranscripts()
transcripts.value = res.data || []
} catch (err) {
showToast(err.response?.data?.detail || '上传文字稿失败', 'error')
} finally {
transcriptUploading.value = false
setTimeout(() => { transcriptProgress.value = 0 }, 500)
}
}
async function handleDeleteTranscript(id) {
try {
await deleteTranscript(id)
transcripts.value = transcripts.value.filter(t => t.id !== id)
} catch (err) {
showToast(err.response?.data?.detail || '删除失败', 'error')
}
}
// ===== Person upload =====
function triggerPersonInput() {
personInputRef.value?.click()
}
function handlePersonSelect(e) {
const files = Array.from(e.target.files || [])
if (files.length > 0) uploadPersonFiles(files)
e.target.value = ''
}
function handlePersonDrop(e) {
personDragging.value = false
const files = Array.from(e.dataTransfer.files || []).filter(f =>
f.name.toLowerCase().endsWith('.docx')
)
if (files.length > 0) {
uploadPersonFiles(files)
} else {
showToast('请上传 .docx 格式的文件', 'error')
}
}
async function uploadPersonFiles(files) {
personUploading.value = true
personProgress.value = 0
try {
await uploadPersons(files)
personProgress.value = 100
const res = await getPersons()
persons.value = res.data || []
} catch (err) {
showToast(err.response?.data?.detail || '上传讲述者信息失败', 'error')
} finally {
personUploading.value = false
setTimeout(() => { personProgress.value = 0 }, 500)
}
}
async function handleDeletePerson(id) {
try {
await deletePerson(id)
persons.value = persons.value.filter(p => p.id !== id)
} catch (err) {
showToast(err.response?.data?.detail || '删除失败', 'error')
}
}
// ===== Load existing data =====
onMounted(async () => {
try {
const [transcriptRes, personRes] = await Promise.all([
getTranscripts(),
getPersons(),
])
transcripts.value = transcriptRes.data || []
persons.value = personRes.data || []
} catch (err) {
showToast('加载文件列表失败', 'error')
}
})
</script>
<style scoped>
.upload-zone.is-dragging {
border-color: var(--primary);
background: var(--primary-bg);
}
.upload-progress {
padding: 12px 0;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="welcome-page">
<div class="welcome-icon">
<i class="fas fa-book-open"></i>
</div>
<h1 class="welcome-title">Story Extractor</h1>
<p class="welcome-desc">
从口述历史文字稿中利用 AI 自动提取个人故事并与讲述者匹配最终生成结构化文档
</p>
<div class="welcome-steps">
<div class="welcome-step-item">
<div class="welcome-step-icon s1"><i class="fas fa-cloud-upload-alt"></i></div>
<span class="welcome-step-label">上传文件</span>
<span class="welcome-step-num">Step 1</span>
</div>
<div class="welcome-step-item">
<div class="welcome-step-icon s2"><i class="fas fa-magic"></i></div>
<span class="welcome-step-label">提取故事</span>
<span class="welcome-step-num">Step 2</span>
</div>
<div class="welcome-step-item">
<div class="welcome-step-icon s3"><i class="fas fa-link"></i></div>
<span class="welcome-step-label">匹配确认</span>
<span class="welcome-step-num">Step 3</span>
</div>
<div class="welcome-step-item">
<div class="welcome-step-icon s4"><i class="fas fa-file-export"></i></div>
<span class="welcome-step-label">预览导出</span>
<span class="welcome-step-num">Step 4</span>
</div>
</div>
<button class="btn btn-primary btn-lg" @click="$emit('next')">
<i class="fas fa-rocket"></i> 开始使用
</button>
</div>
</template>
<script setup>
defineEmits(['next', 'prev', 'go-to'])
</script>

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})