init
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
5
frontend/README.md
Normal 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
15
frontend/index.html
Normal 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
1407
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
frontend/package.json
Normal file
20
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
78
frontend/src/App.vue
Normal 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
60
frontend/src/api/index.js
Normal 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
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
936
frontend/src/assets/styles/global.css
Normal file
936
frontend/src/assets/styles/global.css
Normal 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); }
|
||||
}
|
||||
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
22
frontend/src/components/AppHeader.vue
Normal file
22
frontend/src/components/AppHeader.vue
Normal 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>
|
||||
30
frontend/src/components/AppStepper.vue
Normal file
30
frontend/src/components/AppStepper.vue
Normal 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>
|
||||
37
frontend/src/components/BottomNav.vue
Normal file
37
frontend/src/components/BottomNav.vue
Normal 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>
|
||||
191
frontend/src/components/SettingsModal.vue
Normal file
191
frontend/src/components/SettingsModal.vue
Normal 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>
|
||||
30
frontend/src/components/Toast.vue
Normal file
30
frontend/src/components/Toast.vue
Normal 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>
|
||||
15
frontend/src/composables/useToast.js
Normal file
15
frontend/src/composables/useToast.js
Normal 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
8
frontend/src/main.js
Normal 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')
|
||||
16
frontend/src/router/index.js
Normal file
16
frontend/src/router/index.js
Normal 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
|
||||
316
frontend/src/views/ExportView.vue
Normal file
316
frontend/src/views/ExportView.vue
Normal 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>
|
||||
410
frontend/src/views/ExtractView.vue
Normal file
410
frontend/src/views/ExtractView.vue
Normal 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);">×</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>
|
||||
288
frontend/src/views/MatchView.vue
Normal file
288
frontend/src/views/MatchView.vue
Normal 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>
|
||||
292
frontend/src/views/UploadView.vue
Normal file
292
frontend/src/views/UploadView.vue
Normal 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>
|
||||
40
frontend/src/views/WelcomeView.vue
Normal file
40
frontend/src/views/WelcomeView.vue
Normal 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
15
frontend/vite.config.js
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user