Initial commit: clean project structure
- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+) - Frontend: Vue 3 + TypeScript + Pinia + Tailwind - Admin Frontend: separate Vue 3 app for management - Docker Compose: 9 services orchestration - Specs: design prototypes, memory system PRD, product roadmap Cleanup performed: - Removed temporary debug scripts from backend root - Removed deprecated admin_app.py (embedded UI) - Removed duplicate docs from admin-frontend - Updated .gitignore for Vite cache and egg-info
This commit is contained in:
17
admin-frontend/.gitignore
vendored
Normal file
17
admin-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
23
admin-frontend/Dockerfile
Normal file
23
admin-frontend/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Build Stage
|
||||
FROM node:18-alpine as build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production Stage
|
||||
FROM nginx:alpine as production-stage
|
||||
|
||||
# 复制构建产物到 Nginx
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制自定义 Nginx 配置 (处理 SPA 路由)
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
admin-frontend/index.html
Normal file
13
admin-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!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>DreamWeaver Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
admin-frontend/nginx.conf
Normal file
37
admin-frontend/nginx.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 静态文件服务
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
# SPA 路由支持: 找不到文件时回退到 index.html
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 反向代理: 将 /admin 请求转发给管理后端
|
||||
location /admin/ {
|
||||
proxy_pass http://backend-admin:8001/admin/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 仍保留 /api 以备偶尔调用通用接口(Auth等)
|
||||
location /api/ {
|
||||
proxy_pass http://backend-admin:8001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 静态资源代理
|
||||
location /static/ {
|
||||
proxy_pass http://backend-admin:8001/static/;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
2627
admin-frontend/package-lock.json
generated
Normal file
2627
admin-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
admin-frontend/package.json
Normal file
28
admin-frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "dreamweaver-admin-console",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
}
|
||||
6
admin-frontend/postcss.config.js
Normal file
6
admin-frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
3
admin-frontend/public/favicon.svg
Normal file
3
admin-frontend/public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<text y=".9em" font-size="90">✨</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 113 B |
399
admin-frontend/public/landing.html
Normal file
399
admin-frontend/public/landing.html
Normal file
@@ -0,0 +1,399 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>梦语织机 - AI 儿童故事创作</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-deep: #0D0F1A;
|
||||
--bg-card: #151829;
|
||||
--bg-elevated: #1C2035;
|
||||
--accent: #FFD369;
|
||||
--accent-soft: #FFF0C9;
|
||||
--text: #EAEAEA;
|
||||
--text-secondary: #9CA3AF;
|
||||
--text-muted: #6B7280;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--glow: rgba(255, 211, 105, 0.15);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans SC', -apple-system, sans-serif;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 动画背景 */
|
||||
.animated-bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
|
||||
.glow { position: absolute; border-radius: 50%; filter: blur(80px); opacity: 0.5; will-change: transform; }
|
||||
.glow-1 { width: 600px; height: 600px; background: radial-gradient(circle, rgba(99, 102, 241, 0.4) 0%, transparent 70%); top: -200px; left: -100px; animation: float1 20s ease-in-out infinite; }
|
||||
.glow-2 { width: 500px; height: 500px; background: radial-gradient(circle, rgba(255, 211, 105, 0.3) 0%, transparent 70%); top: 30%; right: -150px; animation: float2 25s ease-in-out infinite; }
|
||||
.glow-3 { width: 400px; height: 400px; background: radial-gradient(circle, rgba(168, 85, 247, 0.35) 0%, transparent 70%); bottom: -100px; left: 20%; animation: float3 22s ease-in-out infinite; }
|
||||
@keyframes float1 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(100px, 50px) scale(1.1); } 66% { transform: translate(50px, 100px) scale(0.9); } }
|
||||
@keyframes float2 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(-80px, 60px) scale(1.15); } 66% { transform: translate(-40px, -40px) scale(0.95); } }
|
||||
@keyframes float3 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(60px, -50px) scale(1.05); } 66% { transform: translate(-30px, 30px) scale(1.1); } }
|
||||
.grid-bg { position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px); background-size: 60px 60px; mask-image: radial-gradient(ellipse 80% 50% at 50% 50%, black 40%, transparent 100%); }
|
||||
.star { position: absolute; width: 2px; height: 2px; background: white; border-radius: 50%; animation: twinkle 3s ease-in-out infinite; }
|
||||
@keyframes twinkle { 0%, 100% { opacity: 0.3; transform: scale(1); } 50% { opacity: 1; transform: scale(1.5); } }
|
||||
|
||||
/* 导航 */
|
||||
nav { position: fixed; top: 0; left: 0; right: 0; padding: 16px 48px; display: flex; justify-content: space-between; align-items: center; background: rgba(13, 15, 26, 0.8); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); z-index: 100; }
|
||||
.logo { font-size: 1.2rem; font-weight: 600; color: var(--accent); letter-spacing: 1px; }
|
||||
.nav-links { display: flex; gap: 36px; list-style: none; }
|
||||
.nav-links a { color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; transition: color 0.2s; }
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-cta { padding: 10px 22px; background: var(--accent); color: var(--bg-deep); border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||||
.nav-cta:hover { box-shadow: 0 0 20px var(--glow); }
|
||||
|
||||
/* 滚动动画 */
|
||||
.fade-in { opacity: 0; transform: translateY(40px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
|
||||
.fade-in.visible { opacity: 1; transform: translateY(0); }
|
||||
.fade-in-left { opacity: 0; transform: translateX(-60px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
|
||||
.fade-in-left.visible { opacity: 1; transform: translateX(0); }
|
||||
.fade-in-right { opacity: 0; transform: translateX(60px); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
|
||||
.fade-in-right.visible { opacity: 1; transform: translateX(0); }
|
||||
.fade-in-scale { opacity: 0; transform: scale(0.9); transition: opacity 0.8s ease-out, transform 0.8s ease-out; }
|
||||
.fade-in-scale.visible { opacity: 1; transform: scale(1); }
|
||||
.delay-1 { transition-delay: 0.1s; }
|
||||
.delay-2 { transition-delay: 0.2s; }
|
||||
.delay-3 { transition-delay: 0.3s; }
|
||||
.delay-4 { transition-delay: 0.4s; }
|
||||
.delay-5 { transition-delay: 0.5s; }
|
||||
|
||||
/* 按钮 */
|
||||
.btn-primary { padding: 16px 32px; background: var(--accent); color: var(--bg-deep); border: none; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-primary:hover { box-shadow: 0 0 30px var(--glow); transform: translateY(-2px); }
|
||||
.btn-ghost { padding: 16px 32px; background: transparent; color: var(--text); border: 1px solid var(--border); border-radius: 10px; font-size: 1rem; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-ghost:hover { background: var(--bg-elevated); border-color: var(--text-muted); }
|
||||
|
||||
/* Hero */
|
||||
.hero { min-height: 100vh; display: flex; align-items: center; padding: 120px 48px 80px; max-width: 1400px; margin: 0 auto; position: relative; z-index: 1; }
|
||||
.hero-content { flex: 1; max-width: 580px; }
|
||||
.hero-badge { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 24px; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 28px; }
|
||||
.hero-badge span { color: var(--accent); }
|
||||
.hero-title { font-size: 3.5rem; font-weight: 700; line-height: 1.2; margin-bottom: 24px; }
|
||||
.hero-title .highlight { color: var(--accent); }
|
||||
.hero-desc { font-size: 1.15rem; color: var(--text-secondary); line-height: 1.8; margin-bottom: 40px; }
|
||||
.hero-buttons { display: flex; gap: 16px; margin-bottom: 56px; }
|
||||
.hero-visual { flex: 1; display: flex; justify-content: flex-end; padding-left: 60px; }
|
||||
.visual-container { position: relative; width: 440px; }
|
||||
|
||||
/* 故事卡片 */
|
||||
.main-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; padding: 24px; backdrop-filter: blur(10px); }
|
||||
.card-cover { aspect-ratio: 16/10; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); border-radius: 14px; margin-bottom: 20px; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
|
||||
.card-cover::before { content: ''; position: absolute; width: 100px; height: 100px; background: radial-gradient(circle, var(--accent) 0%, transparent 70%); opacity: 0.3; top: 20%; left: 30%; filter: blur(20px); }
|
||||
.card-cover-text { font-size: 3rem; opacity: 0.8; animation: bounce 2s ease-in-out infinite; }
|
||||
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
.card-title { font-size: 1.15rem; font-weight: 600; }
|
||||
.card-badge { padding: 4px 10px; background: var(--glow); color: var(--accent); border-radius: 6px; font-size: 0.75rem; font-weight: 500; }
|
||||
.card-excerpt { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.7; margin-bottom: 16px; }
|
||||
.card-tags { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.tag { padding: 6px 12px; background: var(--bg-elevated); color: var(--text-muted); border-radius: 6px; font-size: 0.8rem; border: 1px solid var(--border); }
|
||||
.card-actions { display: flex; gap: 10px; }
|
||||
.action-btn { flex: 1; padding: 12px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; font-size: 0.85rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
.action-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* 浮动卡片 */
|
||||
.float-card { position: absolute; background: var(--bg-card); border: 1px solid var(--border); padding: 12px 18px; border-radius: 12px; backdrop-filter: blur(10px); display: flex; align-items: center; gap: 12px; animation: floatCard 6s ease-in-out infinite; }
|
||||
.float-card-1 { top: 20px; left: -60px; }
|
||||
.float-card-2 { bottom: 80px; right: -40px; animation-delay: 1s; }
|
||||
@keyframes floatCard { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-15px); } }
|
||||
.float-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; background: var(--bg-elevated); }
|
||||
.float-text { font-size: 0.85rem; color: var(--text-secondary); }
|
||||
|
||||
/* Trust Bar */
|
||||
.trust-bar { padding: 60px 48px; background: rgba(255,255,255,0.02); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); position: relative; z-index: 1; }
|
||||
.trust-container { max-width: 900px; margin: 0 auto; display: flex; justify-content: center; gap: 60px; flex-wrap: wrap; }
|
||||
.stat-item { text-align: center; min-width: 140px; }
|
||||
.stat-number { font-size: 2.5rem; font-weight: 700; color: var(--text); line-height: 1; margin-bottom: 8px; }
|
||||
.stat-number .accent { color: var(--accent); }
|
||||
.stat-label { font-size: 0.9rem; color: var(--text-muted); }
|
||||
|
||||
/* Section通用 */
|
||||
.section { padding: 120px 48px; position: relative; z-index: 1; }
|
||||
.section-header { text-align: center; margin-bottom: 72px; }
|
||||
.section-label { font-size: 0.85rem; color: var(--accent); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 16px; }
|
||||
.section-title { font-size: 2.5rem; font-weight: 600; }
|
||||
|
||||
/* Features */
|
||||
.features-container { max-width: 1200px; margin: 0 auto; }
|
||||
.features-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
||||
.feature-card { background: var(--bg-card); border: 1px solid var(--border); padding: 36px 28px; border-radius: 16px; transition: all 0.3s; }
|
||||
.feature-card:hover { border-color: rgba(255, 211, 105, 0.3); transform: translateY(-4px); }
|
||||
.feature-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 24px; margin-bottom: 24px; background: var(--bg-elevated); border: 1px solid var(--border); }
|
||||
.feature-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; }
|
||||
.feature-desc { font-size: 0.95rem; color: var(--text-secondary); line-height: 1.7; }
|
||||
|
||||
/* How It Works */
|
||||
.steps-container { max-width: 800px; margin: 0 auto; }
|
||||
.steps { display: flex; flex-direction: column; gap: 24px; }
|
||||
.step { display: flex; align-items: flex-start; gap: 24px; background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 16px; padding: 28px; transition: all 0.3s; }
|
||||
.step:hover { border-color: rgba(255, 211, 105, 0.3); }
|
||||
.step-number { width: 56px; height: 56px; background: linear-gradient(135deg, var(--accent), #FF9F43); border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 700; color: var(--bg-deep); flex-shrink: 0; }
|
||||
.step-content h3 { font-size: 1.15rem; font-weight: 600; margin-bottom: 8px; }
|
||||
.step-content p { font-size: 0.95rem; color: var(--text-secondary); line-height: 1.7; }
|
||||
|
||||
/* FAQ */
|
||||
.faq-container { max-width: 700px; margin: 0 auto; }
|
||||
.faq-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.faq-item { background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; transition: border-color 0.3s; }
|
||||
.faq-item:hover { border-color: rgba(255,255,255,0.15); }
|
||||
.faq-item.active { border-color: rgba(255, 211, 105, 0.3); }
|
||||
.faq-question { width: 100%; padding: 20px 24px; background: none; border: none; color: var(--text); font-size: 1rem; font-weight: 500; text-align: left; cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 16px; transition: color 0.2s; }
|
||||
.faq-question:hover { color: var(--accent); }
|
||||
.faq-item.active .faq-question { color: var(--accent); }
|
||||
.faq-icon { width: 24px; height: 24px; flex-shrink: 0; position: relative; }
|
||||
.faq-icon::before, .faq-icon::after { content: ''; position: absolute; background: currentColor; border-radius: 2px; transition: transform 0.3s ease; }
|
||||
.faq-icon::before { width: 14px; height: 2px; top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
||||
.faq-icon::after { width: 2px; height: 14px; top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
||||
.faq-item.active .faq-icon::after { transform: translate(-50%, -50%) rotate(90deg); opacity: 0; }
|
||||
.faq-answer-wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease; }
|
||||
.faq-item.active .faq-answer-wrapper { grid-template-rows: 1fr; }
|
||||
.faq-answer { overflow: hidden; }
|
||||
.faq-answer-content { padding: 0 24px 20px; color: var(--text-secondary); line-height: 1.7; }
|
||||
|
||||
/* CTA */
|
||||
.cta { text-align: center; }
|
||||
.cta-container { max-width: 650px; margin: 0 auto; padding: 60px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 24px; }
|
||||
.cta-title { font-size: 2rem; font-weight: 600; margin-bottom: 16px; }
|
||||
.cta-desc { font-size: 1rem; color: var(--text-secondary); margin-bottom: 32px; }
|
||||
|
||||
/* 模态框 */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease; }
|
||||
.modal-overlay.active { opacity: 1; visibility: visible; }
|
||||
.modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; width: 100%; max-width: 500px; max-height: 90vh; overflow-y: auto; transform: scale(0.9) translateY(20px); opacity: 0; transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease; }
|
||||
.modal-overlay.active .modal { transform: scale(1) translateY(0); opacity: 1; }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); }
|
||||
.modal-title { font-size: 1.25rem; font-weight: 600; }
|
||||
.modal-close { width: 36px; height: 36px; border: none; background: rgba(255,255,255,0.05); border-radius: 10px; color: var(--text-secondary); font-size: 1.25rem; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
|
||||
.modal-close:hover { background: rgba(255,255,255,0.1); color: var(--text); }
|
||||
.modal-body { padding: 24px; }
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-label { display: block; font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 8px; }
|
||||
.form-input { width: 100%; padding: 14px 16px; background: rgba(255,255,255,0.05); border: 1px solid var(--border); border-radius: 10px; color: var(--text); font-size: 1rem; transition: border-color 0.2s, box-shadow 0.2s; }
|
||||
.form-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(255, 211, 105, 0.1); }
|
||||
.form-input::placeholder { color: var(--text-muted); }
|
||||
textarea.form-input { min-height: 100px; resize: vertical; }
|
||||
.tags-select { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.tag-option { padding: 8px 16px; background: rgba(255,255,255,0.05); border: 1px solid var(--border); border-radius: 20px; font-size: 0.9rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
|
||||
.tag-option:hover { border-color: rgba(255, 211, 105, 0.3); color: var(--text); }
|
||||
.tag-option.selected { background: rgba(255, 211, 105, 0.15); border-color: var(--accent); color: var(--accent); }
|
||||
.modal-footer { padding: 20px 24px; border-top: 1px solid var(--border); display: flex; gap: 12px; justify-content: flex-end; }
|
||||
|
||||
/* Footer */
|
||||
.footer { padding: 40px 48px; background: var(--bg-elevated); border-top: 1px solid var(--border); text-align: center; position: relative; z-index: 1; }
|
||||
.footer p { color: var(--text-muted); font-size: 0.9rem; }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1100px) {
|
||||
.hero { flex-direction: column; padding: 100px 24px 60px; }
|
||||
.hero-content { max-width: 100%; text-align: center; }
|
||||
.hero-title { font-size: 2.5rem; }
|
||||
.hero-buttons { justify-content: center; }
|
||||
.hero-visual { padding: 50px 0 0; }
|
||||
.visual-container { width: 100%; max-width: 400px; }
|
||||
.float-card { display: none; }
|
||||
.features-grid { grid-template-columns: 1fr; }
|
||||
nav { padding: 14px 20px; }
|
||||
.nav-links { display: none; }
|
||||
.section { padding: 80px 24px; }
|
||||
.trust-bar { padding: 40px 24px; }
|
||||
.trust-container { gap: 40px; }
|
||||
.step { flex-direction: column; text-align: center; }
|
||||
.cta-container { padding: 40px 24px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="animated-bg">
|
||||
<div class="glow glow-1"></div>
|
||||
<div class="glow glow-2"></div>
|
||||
<div class="glow glow-3"></div>
|
||||
<div class="grid-bg"></div>
|
||||
<div class="stars" id="stars"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="logo">梦语织机</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#features">功能</a></li>
|
||||
<li><a href="#how-it-works">使用方法</a></li>
|
||||
<li><a href="#faq">常见问题</a></li>
|
||||
</ul>
|
||||
<button class="nav-cta" onclick="openModal()">开始创作</button>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-badge fade-in"><span>✨</span> 专为 3-8 岁儿童设计</div>
|
||||
<h1 class="hero-title fade-in delay-1">为孩子编织<br><span class="highlight">专属的童话梦境</span></h1>
|
||||
<p class="hero-desc fade-in delay-2">输入几个关键词,AI 即刻为孩子创作独一无二的睡前故事。温暖的声音,精美的插画,让每个夜晚都充满想象。</p>
|
||||
<div class="hero-buttons fade-in delay-3">
|
||||
<button class="btn-primary" onclick="openModal()">免费开始创作</button>
|
||||
<button class="btn-ghost" onclick="document.getElementById('features').scrollIntoView({behavior:'smooth'})">了解更多</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual fade-in-right delay-2">
|
||||
<div class="visual-container">
|
||||
<div class="float-card float-card-1"><div class="float-icon">🎨</div><span class="float-text">AI 生成插画</span></div>
|
||||
<div class="main-card">
|
||||
<div class="card-cover"><span class="card-cover-text">🐰</span></div>
|
||||
<div class="card-header"><h3 class="card-title">小兔子的勇气冒险</h3><span class="card-badge">刚刚生成</span></div>
|
||||
<p class="card-excerpt">在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔。今天,她决定独自去森林深处...</p>
|
||||
<div class="card-tags"><span class="tag">勇气</span><span class="tag">冒险</span><span class="tag">友谊</span></div>
|
||||
<div class="card-actions"><button class="action-btn">🔊 播放朗读</button><button class="action-btn">🖼 生成插画</button></div>
|
||||
</div>
|
||||
<div class="float-card float-card-2"><div class="float-icon">🔊</div><span class="float-text">温暖语音朗读</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trust Bar -->
|
||||
<section class="trust-bar" id="trust-bar">
|
||||
<div class="trust-container">
|
||||
<div class="stat-item fade-in"><div class="stat-number"><span class="counter" data-target="10000">0</span><span class="accent">+</span></div><div class="stat-label">故事已创作</div></div>
|
||||
<div class="stat-item fade-in delay-1"><div class="stat-number"><span class="counter" data-target="5000">0</span><span class="accent">+</span></div><div class="stat-label">家庭信赖</div></div>
|
||||
<div class="stat-item fade-in delay-2"><div class="stat-number"><span class="counter" data-target="98">0</span><span class="accent">%</span></div><div class="stat-label">满意度</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="section" id="features">
|
||||
<div class="features-container">
|
||||
<div class="section-header"><p class="section-label fade-in">核心功能</p><h2 class="section-title fade-in delay-1">为什么选择梦语织机</h2></div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card fade-in-scale delay-1"><div class="feature-icon">✍️</div><h3 class="feature-title">智能创作</h3><p class="feature-desc">输入关键词或简单想法,AI 即刻创作充满想象力的原创故事</p></div>
|
||||
<div class="feature-card fade-in-scale delay-2"><div class="feature-icon">🧒</div><h3 class="feature-title">个性化记忆</h3><p class="feature-desc">系统记住孩子的喜好,故事越来越懂 TA</p></div>
|
||||
<div class="feature-card fade-in-scale delay-3"><div class="feature-icon">🎨</div><h3 class="feature-title">精美插画</h3><p class="feature-desc">为每个故事自动生成独特的封面插画</p></div>
|
||||
<div class="feature-card fade-in-scale delay-1"><div class="feature-icon">🔊</div><h3 class="feature-title">温暖朗读</h3><p class="feature-desc">专业配音,陪伴孩子进入甜美梦乡</p></div>
|
||||
<div class="feature-card fade-in-scale delay-2"><div class="feature-icon">📚</div><h3 class="feature-title">教育主题</h3><p class="feature-desc">勇气、友谊、分享...自然传递正向价值观</p></div>
|
||||
<div class="feature-card fade-in-scale delay-3"><div class="feature-icon">🌍</div><h3 class="feature-title">故事宇宙</h3><p class="feature-desc">创建专属世界观,角色可在不同故事中复用</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="section" id="how-it-works">
|
||||
<div class="steps-container">
|
||||
<div class="section-header"><p class="section-label fade-in">使用方法</p><h2 class="section-title fade-in delay-1">简单三步,创造专属故事</h2></div>
|
||||
<div class="steps">
|
||||
<div class="step fade-in-left delay-1"><div class="step-number">1</div><div class="step-content"><h3>输入灵感</h3><p>几个关键词或简单想法,比如"勇敢的小兔子在森林里冒险"</p></div></div>
|
||||
<div class="step fade-in-right delay-2"><div class="step-number">2</div><div class="step-content"><h3>AI 创作</h3><p>AI 即刻理解并创作充满想象力的原创故事,还能生成精美插画</p></div></div>
|
||||
<div class="step fade-in-left delay-3"><div class="step-number">3</div><div class="step-content"><h3>温暖朗读</h3><p>选择喜欢的声音,让温暖的朗读陪伴孩子进入甜美梦乡</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section class="section" id="faq">
|
||||
<div class="faq-container">
|
||||
<div class="section-header"><p class="section-label fade-in">常见问题</p><h2 class="section-title fade-in delay-1">你可能想知道</h2></div>
|
||||
<div class="faq-list">
|
||||
<div class="faq-item fade-in delay-1"><button class="faq-question" onclick="toggleFaq(this)"><span>梦语织机适合多大的孩子?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">梦语织机专为 3-8 岁儿童设计。我们的故事内容、语言难度和主题都经过精心调整,确保适合这个年龄段孩子的认知发展水平。</div></div></div></div>
|
||||
<div class="faq-item fade-in delay-2"><button class="faq-question" onclick="toggleFaq(this)"><span>生成的故事内容安全吗?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">绝对安全。所有生成的故事都经过多层内容审核,确保不包含任何不适合儿童的内容。我们的 AI 模型经过专门训练,只会生成积极、正向、富有教育意义的故事内容。</div></div></div></div>
|
||||
<div class="faq-item fade-in delay-3"><button class="faq-question" onclick="toggleFaq(this)"><span>可以自定义故事的主角吗?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">当然可以!您可以输入孩子喜欢的角色名称、特征,甚至可以把孩子自己设定为故事主角。AI 会根据您的输入创作独一无二的个性化故事。</div></div></div></div>
|
||||
<div class="faq-item fade-in delay-4"><button class="faq-question" onclick="toggleFaq(this)"><span>免费版和付费版有什么区别?</span><span class="faq-icon"></span></button><div class="faq-answer-wrapper"><div class="faq-answer"><div class="faq-answer-content">免费版每月可生成 5 个故事,包含基础的语音朗读功能。付费版提供无限故事生成、高级语音选择、AI 插画生成、故事导出等更多功能。</div></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="section cta">
|
||||
<div class="cta-container fade-in-scale">
|
||||
<h2 class="cta-title">准备好为孩子创造魔法了吗?</h2>
|
||||
<p class="cta-desc">免费开始,无需信用卡</p>
|
||||
<button class="btn-primary" onclick="openModal()">立即开始创作</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<p>© 2024 梦语织机 DreamWeaver. All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
<!-- 创作模态框 -->
|
||||
<div class="modal-overlay" id="createModal" onclick="closeModalOnOverlay(event)">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h2 class="modal-title">创作新故事</h2><button class="modal-close" onclick="closeModal()">×</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group"><label class="form-label">故事主角</label><input type="text" class="form-input" placeholder="例如:小兔子、勇敢的公主..."></div>
|
||||
<div class="form-group"><label class="form-label">故事场景</label><input type="text" class="form-input" placeholder="例如:魔法森林、星空下..."></div>
|
||||
<div class="form-group"><label class="form-label">选择主题</label><div class="tags-select"><span class="tag-option" onclick="toggleTag(this)">勇气</span><span class="tag-option" onclick="toggleTag(this)">友谊</span><span class="tag-option" onclick="toggleTag(this)">冒险</span><span class="tag-option" onclick="toggleTag(this)">分享</span><span class="tag-option" onclick="toggleTag(this)">成长</span></div></div>
|
||||
<div class="form-group"><label class="form-label">额外要求(可选)</label><textarea class="form-input" placeholder="任何特殊要求..."></textarea></div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn-ghost" onclick="closeModal()">取消</button><button class="btn-primary">开始创作 ✨</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 生成星星
|
||||
const starsContainer = document.getElementById('stars');
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const star = document.createElement('div');
|
||||
star.className = 'star';
|
||||
star.style.left = Math.random() * 100 + '%';
|
||||
star.style.top = Math.random() * 100 + '%';
|
||||
star.style.animationDelay = Math.random() * 3 + 's';
|
||||
star.style.animationDuration = (2 + Math.random() * 2) + 's';
|
||||
starsContainer.appendChild(star);
|
||||
}
|
||||
|
||||
// 滚动动画
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) entry.target.classList.add('visible');
|
||||
});
|
||||
}, { threshold: 0.15 });
|
||||
document.querySelectorAll('.fade-in, .fade-in-left, .fade-in-right, .fade-in-scale').forEach(el => observer.observe(el));
|
||||
|
||||
// 数字计数动画
|
||||
function easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }
|
||||
function animateCounter(el, target, duration = 2000) {
|
||||
const start = performance.now();
|
||||
function update(now) {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
el.textContent = Math.floor(target * easeOutQuart(progress)).toLocaleString();
|
||||
if (progress < 1) requestAnimationFrame(update);
|
||||
}
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
const counterObserver = new IntersectionObserver((entries, obs) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.querySelectorAll('.counter').forEach((c, i) => {
|
||||
setTimeout(() => animateCounter(c, parseInt(c.dataset.target)), i * 200);
|
||||
});
|
||||
obs.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
document.querySelectorAll('#trust-bar').forEach(el => counterObserver.observe(el));
|
||||
|
||||
// FAQ 手风琴
|
||||
function toggleFaq(btn) { btn.closest('.faq-item').classList.toggle('active'); }
|
||||
|
||||
// 模态框
|
||||
let lastFocus = null;
|
||||
function openModal() {
|
||||
lastFocus = document.activeElement;
|
||||
document.getElementById('createModal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
setTimeout(() => document.querySelector('.modal .form-input')?.focus(), 100);
|
||||
}
|
||||
function closeModal() {
|
||||
document.getElementById('createModal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
lastFocus?.focus();
|
||||
}
|
||||
function closeModalOnOverlay(e) { if (e.target.classList.contains('modal-overlay')) closeModal(); }
|
||||
function toggleTag(el) { el.classList.toggle('selected'); }
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
53
admin-frontend/src/App.vue
Normal file
53
admin-frontend/src/App.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from './stores/user'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 首页使用独立布局,不显示全局导航栏和 footer
|
||||
const isHomePage = computed(() => route.path === '/')
|
||||
|
||||
onMounted(() => {
|
||||
userStore.fetchSession()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 首页:独立布局 -->
|
||||
<template v-if="isHomePage">
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<!-- 其他页面:标准布局 -->
|
||||
<template v-else>
|
||||
<div class="min-h-screen bg-pattern">
|
||||
<NavBar />
|
||||
<main class="container mx-auto px-4 py-8 pb-20">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-4"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-4"
|
||||
>
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
<footer class="fixed bottom-0 left-0 right-0 py-4 text-center text-gray-400 text-sm glass border-t border-white/20">
|
||||
<p>
|
||||
<span class="gradient-text font-medium">梦语织机</span>
|
||||
<span class="mx-2">·</span>
|
||||
<span>为孩子编织专属的童话梦境</span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
45
admin-frontend/src/api/client.ts
Normal file
45
admin-frontend/src/api/client.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const BASE_URL = ''
|
||||
|
||||
class ApiClient {
|
||||
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${BASE_URL}${url}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: '请求失败' }))
|
||||
throw new Error(error.detail || '请求失败')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
get<T>(url: string): Promise<T> {
|
||||
return this.request<T>(url)
|
||||
}
|
||||
|
||||
post<T>(url: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(url, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
put<T>(url: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(url, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
delete<T>(url: string): Promise<T> {
|
||||
return this.request<T>(url, { method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient()
|
||||
376
admin-frontend/src/components/CreateStoryModal.vue
Normal file
376
admin-frontend/src/components/CreateStoryModal.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useStorybookStore } from '../stores/storybook'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from './ui/BaseButton.vue'
|
||||
import BaseInput from './ui/BaseInput.vue'
|
||||
import BaseSelect from './ui/BaseSelect.vue'
|
||||
import BaseTextarea from './ui/BaseTextarea.vue'
|
||||
import {
|
||||
SparklesIcon,
|
||||
PencilSquareIcon,
|
||||
BookOpenIcon,
|
||||
PhotoIcon,
|
||||
XMarkIcon,
|
||||
ExclamationCircleIcon,
|
||||
ShieldCheckIcon,
|
||||
UserGroupIcon,
|
||||
ShareIcon,
|
||||
CheckBadgeIcon,
|
||||
ArrowPathIcon,
|
||||
HeartIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const storybookStore = useStorybookStore()
|
||||
|
||||
// State
|
||||
const inputType = ref<'keywords' | 'full_story'>('keywords')
|
||||
const outputMode = ref<'full_story' | 'storybook'>('full_story')
|
||||
const inputData = ref('')
|
||||
const educationTheme = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Data
|
||||
interface ChildProfile {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
interface StoryUniverse {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
const profiles = ref<ChildProfile[]>([])
|
||||
const universes = ref<StoryUniverse[]>([])
|
||||
const selectedProfileId = ref('')
|
||||
const selectedUniverseId = ref('')
|
||||
const profileError = ref('')
|
||||
|
||||
// Themes
|
||||
type ThemeOption = { icon: Component; label: string; value: string }
|
||||
const themes: ThemeOption[] = [
|
||||
{ icon: ShieldCheckIcon, label: t('home.themeCourage'), value: '勇气' },
|
||||
{ icon: UserGroupIcon, label: t('home.themeFriendship'), value: '友谊' },
|
||||
{ icon: ShareIcon, label: t('home.themeSharing'), value: '分享' },
|
||||
{ icon: CheckBadgeIcon, label: t('home.themeHonesty'), value: '诚实' },
|
||||
{ icon: ArrowPathIcon, label: t('home.themePersistence'), value: '坚持' },
|
||||
{ icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' },
|
||||
]
|
||||
|
||||
const profileOptions = computed(() =>
|
||||
profiles.value.map(profile => ({ value: profile.id, label: profile.name })),
|
||||
)
|
||||
const universeOptions = computed(() =>
|
||||
universes.value.map(universe => ({ value: universe.id, label: universe.name })),
|
||||
)
|
||||
|
||||
// Methods
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function fetchProfiles() {
|
||||
if (!userStore.user) return
|
||||
profileError.value = ''
|
||||
try {
|
||||
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
|
||||
profiles.value = data.profiles
|
||||
if (!selectedProfileId.value && profiles.value.length > 0) {
|
||||
selectedProfileId.value = profiles.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
profileError.value = e instanceof Error ? e.message : '档案加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUniverses(profileId: string) {
|
||||
selectedUniverseId.value = ''
|
||||
if (!profileId) {
|
||||
universes.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.get<{ universes: StoryUniverse[] }>(`/api/profiles/${profileId}/universes`)
|
||||
universes.value = data.universes
|
||||
if (universes.value.length > 0) {
|
||||
selectedUniverseId.value = universes.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
profileError.value = e instanceof Error ? e.message : '宇宙加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedProfileId, (newId) => {
|
||||
if (newId) fetchUniverses(newId)
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
fetchProfiles()
|
||||
}
|
||||
})
|
||||
|
||||
async function generateStory() {
|
||||
if (!inputData.value.trim()) {
|
||||
error.value = t('home.errorEmpty')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
type: inputType.value,
|
||||
data: inputData.value,
|
||||
education_theme: educationTheme.value || undefined,
|
||||
}
|
||||
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
|
||||
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
|
||||
|
||||
if (outputMode.value === 'storybook') {
|
||||
const response = await api.post<any>('/api/storybook/generate', {
|
||||
keywords: inputData.value,
|
||||
education_theme: educationTheme.value || undefined,
|
||||
generate_images: true,
|
||||
page_count: 6,
|
||||
child_profile_id: selectedProfileId.value || undefined,
|
||||
universe_id: selectedUniverseId.value || undefined
|
||||
})
|
||||
|
||||
storybookStore.setStorybook(response)
|
||||
close()
|
||||
router.push('/storybook/view')
|
||||
} else {
|
||||
const result = await api.post<any>('/api/generate/full', payload)
|
||||
const query: Record<string, string> = {}
|
||||
if (result.errors && Object.keys(result.errors).length > 0) {
|
||||
if (result.errors.image) query.imageError = '1'
|
||||
}
|
||||
close()
|
||||
router.push({ path: `/story/${result.id}`, query })
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '生成失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-300"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click="close"
|
||||
></div>
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8">
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="close"
|
||||
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors z-10"
|
||||
>
|
||||
<XMarkIcon class="h-6 w-6 text-gray-400" />
|
||||
</button>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h2 class="text-2xl font-bold text-gray-100 mb-6">
|
||||
{{ t('home.createModalTitle') }}
|
||||
</h2>
|
||||
|
||||
<!-- 输入类型切换 -->
|
||||
<div class="flex space-x-3 mb-6">
|
||||
<button
|
||||
@click="inputType = 'keywords'"
|
||||
:class="[
|
||||
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
|
||||
inputType === 'keywords'
|
||||
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<SparklesIcon class="h-5 w-5" />
|
||||
<span>{{ t('home.inputTypeKeywords') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="inputType = 'full_story'"
|
||||
:class="[
|
||||
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
|
||||
inputType === 'full_story'
|
||||
? 'btn-magic text-[#0D0F1A] shadow-lg bg-gradient-to-r from-[#FFD369] to-[#FF9F43]'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<PencilSquareIcon class="h-5 w-5" />
|
||||
<span>{{ t('home.inputTypeStory') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 呈现形式选择 (仅在关键词模式下可用) -->
|
||||
<div class="flex space-x-3 mb-6" v-if="inputType === 'keywords'">
|
||||
<button
|
||||
@click="outputMode = 'full_story'"
|
||||
:class="[
|
||||
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
|
||||
outputMode === 'full_story'
|
||||
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-lg'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<BookOpenIcon class="h-5 w-5" />
|
||||
<span>普通故事</span>
|
||||
</button>
|
||||
<button
|
||||
@click="outputMode = 'storybook'"
|
||||
:class="[
|
||||
'flex-1 py-3 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center space-x-2',
|
||||
outputMode === 'storybook'
|
||||
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-lg'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<PhotoIcon class="h-5 w-5" />
|
||||
<span>绘本模式</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 孩子档案选择 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-300 font-semibold mb-2">
|
||||
{{ t('home.selectProfile') }}
|
||||
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.selectProfileOptional') }}</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<BaseSelect
|
||||
v-model="selectedProfileId"
|
||||
:options="[{ value: '', label: t('home.noProfile') }, ...profileOptions]"
|
||||
/>
|
||||
<BaseSelect
|
||||
v-model="selectedUniverseId"
|
||||
:options="[{ value: '', label: t('home.noUniverse') }, ...universeOptions]"
|
||||
:disabled="!selectedProfileId || universes.length === 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="profileError" class="text-sm text-red-500 mt-2">{{ profileError }}</div>
|
||||
<div v-if="selectedProfileId && universes.length === 0" class="text-sm text-gray-500 mt-2">
|
||||
{{ t('home.noUniverseHint') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-300 font-semibold mb-2">
|
||||
{{ inputType === 'keywords' ? t('home.inputLabel') : t('home.inputLabelStory') }}
|
||||
</label>
|
||||
<BaseTextarea
|
||||
v-model="inputData"
|
||||
:placeholder="inputType === 'keywords' ? t('home.inputPlaceholder') : t('home.inputPlaceholderStory')"
|
||||
:rows="5"
|
||||
:maxLength="5000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 教育主题选择 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-300 font-semibold mb-2">
|
||||
{{ t('home.themeLabel') }}
|
||||
<span class="text-gray-500 font-normal text-sm ml-1">{{ t('home.themeOptional') }}</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.value"
|
||||
@click="educationTheme = educationTheme === theme.value ? '' : theme.value"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg font-medium transition-all duration-300 flex items-center space-x-1.5 text-sm',
|
||||
educationTheme === theme.value
|
||||
? 'bg-gradient-to-r from-[#FFD369] to-[#FF9F43] text-[#0D0F1A] shadow-md'
|
||||
: 'bg-[#1C2035] text-gray-400 hover:border-[#FFD369] border border-white/10'
|
||||
]"
|
||||
>
|
||||
<component :is="theme.icon" class="h-4 w-4" />
|
||||
<span>{{ theme.label }}</span>
|
||||
</button>
|
||||
<BaseInput
|
||||
v-model="educationTheme"
|
||||
:placeholder="t('home.themeCustom')"
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-300"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="error" class="mb-4 p-3 bg-red-900/20 border border-red-800 text-red-400 rounded-xl flex items-center space-x-2">
|
||||
<ExclamationCircleIcon class="h-5 w-5 flex-shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@click="generateStory"
|
||||
>
|
||||
<template v-if="loading">
|
||||
{{ t('home.generating') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||
{{ t('home.startCreate') }}
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 临时添加一些 btn-magic 样式确保兼容 */
|
||||
.btn-magic {
|
||||
background: linear-gradient(135deg, #FFD369 0%, #FF9F43 100%);
|
||||
color: #0D0F1A;
|
||||
}
|
||||
</style>
|
||||
112
admin-frontend/src/components/NavBar.vue
Normal file
112
admin-frontend/src/components/NavBar.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { setLocale } from '../i18n'
|
||||
import {
|
||||
ArrowRightOnRectangleIcon,
|
||||
MoonIcon,
|
||||
SparklesIcon,
|
||||
StarIcon,
|
||||
SunIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const isDark = ref(false)
|
||||
|
||||
// 管理员状态直接读取 Storage
|
||||
const isLoggedIn = computed(() => !!sessionStorage.getItem('admin_auth'))
|
||||
|
||||
function logout() {
|
||||
sessionStorage.removeItem('admin_auth')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function switchLocale(lang: 'en' | 'zh') {
|
||||
setLocale(lang)
|
||||
}
|
||||
|
||||
function applyTheme(value: boolean) {
|
||||
document.documentElement.classList.toggle('dark', value)
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
applyTheme(isDark.value)
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme) {
|
||||
isDark.value = savedTheme === 'dark'
|
||||
} else if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
|
||||
isDark.value = true
|
||||
}
|
||||
applyTheme(isDark.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="glass sticky top-0 z-50 border-b border-white/20">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-18 py-3">
|
||||
<router-link to="/" class="flex items-center space-x-3 group">
|
||||
<div class="relative">
|
||||
<SparklesIcon class="h-8 w-8 text-purple-500" />
|
||||
<StarIcon class="absolute -top-1 -right-1 h-3.5 w-3.5 text-pink-400" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-2xl font-bold gradient-text">
|
||||
DreamWeaver
|
||||
</span>
|
||||
<span class="inline-block px-2 py-0.5 ml-2 text-xs font-bold text-white bg-purple-600 rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
class="px-3 py-2 text-sm rounded-lg hover:bg-white/50 transition"
|
||||
:class="{ 'bg-white/70': locale === 'en' }"
|
||||
@click="switchLocale('en')"
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm rounded-lg hover:bg-white/50 transition"
|
||||
:class="{ 'bg-white/70': locale === 'zh' }"
|
||||
@click="switchLocale('zh')"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-white/50 transition"
|
||||
@click="toggleTheme"
|
||||
:aria-pressed="isDark"
|
||||
>
|
||||
<SunIcon v-if="isDark" class="h-5 w-5 text-amber-500" />
|
||||
<MoonIcon v-else class="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
|
||||
<!-- 管理员已登录状态 -->
|
||||
<div v-if="isLoggedIn" class="relative ml-4 pl-4 border-l border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-right hidden sm:block">
|
||||
<div class="text-sm font-bold text-gray-800">Administrator</div>
|
||||
<div class="text-xs text-gray-500">Super User</div>
|
||||
</div>
|
||||
<button
|
||||
@click="logout"
|
||||
class="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="退出登录"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
87
admin-frontend/src/components/ui/BaseButton.vue
Normal file
87
admin-frontend/src/components/ui/BaseButton.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
as?: string | Record<string, unknown>
|
||||
}>(),
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
as: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const isButton = computed(() => props.as === 'button' || !props.as)
|
||||
const isDisabled = computed(() => props.disabled || props.loading)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
if (props.size === 'sm') return 'px-3 py-2 text-sm rounded-lg'
|
||||
if (props.size === 'lg') return 'px-6 py-3 text-base rounded-xl'
|
||||
return 'px-4 py-2.5 text-sm rounded-xl'
|
||||
})
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'secondary':
|
||||
return 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
case 'danger':
|
||||
return 'bg-red-500 text-white hover:bg-red-600'
|
||||
case 'ghost':
|
||||
return 'bg-transparent text-gray-600 hover:bg-gray-100'
|
||||
default:
|
||||
return 'btn-magic text-white'
|
||||
}
|
||||
})
|
||||
|
||||
const baseClasses = computed(() => [
|
||||
'inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-300',
|
||||
sizeClasses.value,
|
||||
variantClasses.value,
|
||||
isDisabled.value ? 'opacity-60 cursor-not-allowed' : '',
|
||||
])
|
||||
|
||||
const passthroughAttrs = computed(() => {
|
||||
const { class: _class, type: _type, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (!isButton.value && isDisabled.value) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.as || 'button'"
|
||||
:type="isButton ? (attrs.type as string || 'button') : undefined"
|
||||
:disabled="isButton ? isDisabled : undefined"
|
||||
:aria-disabled="!isButton && isDisabled ? 'true' : undefined"
|
||||
:class="[baseClasses, attrs.class]"
|
||||
v-bind="passthroughAttrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span
|
||||
v-if="props.loading"
|
||||
class="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<component v-else-if="props.icon" :is="props.icon" class="h-5 w-5" aria-hidden="true" />
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
43
admin-frontend/src/components/ui/BaseCard.vue
Normal file
43
admin-frontend/src/components/ui/BaseCard.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
hover?: boolean
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}>(),
|
||||
{
|
||||
hover: false,
|
||||
padding: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const paddingClasses = computed(() => {
|
||||
switch (props.padding) {
|
||||
case 'none':
|
||||
return 'p-0'
|
||||
case 'sm':
|
||||
return 'p-4'
|
||||
case 'lg':
|
||||
return 'p-8'
|
||||
default:
|
||||
return 'p-6'
|
||||
}
|
||||
})
|
||||
|
||||
const baseClasses = computed(() => [
|
||||
'glass rounded-2xl',
|
||||
paddingClasses.value,
|
||||
props.hover ? 'card-hover' : '',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[baseClasses, attrs.class]" v-bind="attrs">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
71
admin-frontend/src/components/ui/BaseInput.vue
Normal file
71
admin-frontend/src/components/ui/BaseInput.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | number | null
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'date'
|
||||
placeholder?: string
|
||||
label?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
modelModifiers?: { number?: boolean; trim?: boolean }
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
label: '',
|
||||
error: '',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||
const attrs = useAttrs()
|
||||
const uid = `input-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const inputId = computed(() => (attrs.id as string) || uid)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 placeholder-gray-400 focus:outline-none',
|
||||
props.error ? 'ring-2 ring-red-200' : '',
|
||||
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
])
|
||||
|
||||
const passthroughAttrs = computed(() => {
|
||||
const { class: _class, id: _id, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label v-if="props.label" :for="inputId" class="text-sm font-medium text-gray-700">
|
||||
{{ props.label }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:type="props.type"
|
||||
:placeholder="props.placeholder"
|
||||
:value="props.modelValue ?? ''"
|
||||
:disabled="props.disabled"
|
||||
:class="[inputClasses, attrs.class]"
|
||||
v-bind="passthroughAttrs"
|
||||
@input="(event) => {
|
||||
let value = (event.target as HTMLInputElement).value
|
||||
if (props.modelModifiers?.trim) value = value.trim()
|
||||
if (props.modelModifiers?.number) {
|
||||
const nextValue = Number(value)
|
||||
emit('update:modelValue', Number.isNaN(nextValue) ? value : nextValue)
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}"
|
||||
/>
|
||||
<p v-if="props.error" class="text-sm text-red-500">
|
||||
{{ props.error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
67
admin-frontend/src/components/ui/BaseSelect.vue
Normal file
67
admin-frontend/src/components/ui/BaseSelect.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
type Option = { value: string | number; label: string }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | number | null
|
||||
options: Option[]
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||
const attrs = useAttrs()
|
||||
const uid = `select-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const selectId = computed(() => (attrs.id as string) || uid)
|
||||
|
||||
const selectClasses = computed(() => [
|
||||
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 focus:outline-none',
|
||||
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
])
|
||||
|
||||
const passthroughAttrs = computed(() => {
|
||||
const { class: _class, id: _id, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
|
||||
function handleChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
const matched = props.options.find(option => String(option.value) === value)
|
||||
emit('update:modelValue', matched ? matched.value : value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label v-if="props.label" :for="selectId" class="text-sm font-medium text-gray-700">
|
||||
{{ props.label }}
|
||||
</label>
|
||||
<select
|
||||
:id="selectId"
|
||||
:value="props.modelValue ?? ''"
|
||||
:disabled="props.disabled"
|
||||
:class="[selectClasses, attrs.class]"
|
||||
v-bind="passthroughAttrs"
|
||||
@change="handleChange"
|
||||
>
|
||||
<option v-if="props.placeholder" value="">
|
||||
{{ props.placeholder }}
|
||||
</option>
|
||||
<option v-for="option in props.options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
62
admin-frontend/src/components/ui/BaseTextarea.vue
Normal file
62
admin-frontend/src/components/ui/BaseTextarea.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
maxLength?: number
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
rows: 4,
|
||||
label: '',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
||||
const attrs = useAttrs()
|
||||
const uid = `textarea-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const textareaId = computed(() => (attrs.id as string) || uid)
|
||||
|
||||
const textareaClasses = computed(() => [
|
||||
'input-magic w-full px-4 py-3 rounded-xl text-gray-700 placeholder-gray-400 focus:outline-none resize-none',
|
||||
props.disabled ? 'opacity-60 cursor-not-allowed' : '',
|
||||
])
|
||||
|
||||
const passthroughAttrs = computed(() => {
|
||||
const { class: _class, id: _id, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label v-if="props.label" :for="textareaId" class="text-sm font-medium text-gray-700">
|
||||
{{ props.label }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
:id="textareaId"
|
||||
:rows="props.rows"
|
||||
:placeholder="props.placeholder"
|
||||
:value="props.modelValue"
|
||||
:maxlength="props.maxLength"
|
||||
:disabled="props.disabled"
|
||||
:class="[textareaClasses, attrs.class]"
|
||||
v-bind="passthroughAttrs"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
<div v-if="props.maxLength" class="absolute bottom-3 right-4 text-xs text-gray-400">
|
||||
{{ props.modelValue.length }} / {{ props.maxLength }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
60
admin-frontend/src/components/ui/ConfirmModal.vue
Normal file
60
admin-frontend/src/components/ui/ConfirmModal.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
}>(),
|
||||
{
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
variant: 'info',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||
|
||||
const confirmVariant = computed(() => (props.variant === 'danger' ? 'danger' : 'primary'))
|
||||
const headerClasses = computed(() => {
|
||||
if (props.variant === 'danger') return 'text-red-600'
|
||||
if (props.variant === 'warning') return 'text-amber-600'
|
||||
return 'text-gray-800'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-all duration-200"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="props.show" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="emit('cancel')"></div>
|
||||
<div class="relative glass rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h3 class="text-lg font-semibold" :class="headerClasses">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
{{ props.message }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<BaseButton variant="secondary" @click="emit('cancel')">
|
||||
{{ props.cancelText }}
|
||||
</BaseButton>
|
||||
<BaseButton :variant="confirmVariant" @click="emit('confirm')">
|
||||
{{ props.confirmText }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
45
admin-frontend/src/components/ui/EmptyState.vue
Normal file
45
admin-frontend/src/components/ui/EmptyState.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon: Component
|
||||
title: string
|
||||
description: string
|
||||
actionText?: string
|
||||
actionTo?: string
|
||||
}>(),
|
||||
{
|
||||
actionText: '',
|
||||
actionTo: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
|
||||
function handleAction() {
|
||||
if (!props.actionTo) emit('action')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-12">
|
||||
<component :is="props.icon" class="h-14 w-14 text-purple-400" aria-hidden="true" />
|
||||
<h3 class="mt-4 text-xl font-semibold text-gray-800">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
{{ props.description }}
|
||||
</p>
|
||||
<BaseButton
|
||||
v-if="props.actionText"
|
||||
:as="props.actionTo ? 'router-link' : 'button'"
|
||||
:to="props.actionTo || undefined"
|
||||
class="mt-6"
|
||||
@click="handleAction"
|
||||
>
|
||||
{{ props.actionText }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
36
admin-frontend/src/components/ui/LoadingSpinner.vue
Normal file
36
admin-frontend/src/components/ui/LoadingSpinner.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
text?: string
|
||||
}>(),
|
||||
{
|
||||
size: 'md',
|
||||
text: '',
|
||||
},
|
||||
)
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'h-6 w-6 border-2'
|
||||
case 'lg':
|
||||
return 'h-14 w-14 border-4'
|
||||
default:
|
||||
return 'h-10 w-10 border-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div
|
||||
:class="['animate-spin rounded-full border-purple-200 border-t-purple-500 border-r-pink-400', sizeClasses]"
|
||||
></div>
|
||||
<p v-if="props.text" class="mt-4 text-sm text-gray-500">
|
||||
{{ props.text }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
136
admin-frontend/src/components/ui/LoginDialog.vue
Normal file
136
admin-frontend/src/components/ui/LoginDialog.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function loginWithGithub() {
|
||||
window.location.href = '/auth/github/signin'
|
||||
}
|
||||
|
||||
function loginWithGoogle() {
|
||||
window.location.href = '/auth/google/signin'
|
||||
}
|
||||
|
||||
function loginWithDev() {
|
||||
window.location.href = '/auth/dev/signin'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-300"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click="close"
|
||||
></div>
|
||||
|
||||
<!-- 对话框 -->
|
||||
<div class="login-dialog glass rounded-3xl shadow-2xl p-8 w-full max-w-sm relative">
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="close"
|
||||
class="absolute top-4 right-4 p-2 rounded-full hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
>
|
||||
<XMarkIcon class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-3xl mb-3">✨</div>
|
||||
<h2 class="text-xl font-bold text-[var(--text)] mb-2">
|
||||
登录开始创作
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
选择你的登录方式
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
@click="loginWithDev"
|
||||
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300 bg-gray-700/50 hover:bg-gray-700 text-white border-dashed border-gray-600"
|
||||
>
|
||||
<CommandLineIcon class="w-5 h-5" />
|
||||
<span>开发模式一键登录</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="loginWithGithub"
|
||||
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
<span>使用 GitHub 登录</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="loginWithGoogle"
|
||||
class="login-btn w-full py-3.5 px-4 rounded-xl font-semibold flex items-center justify-center gap-3 transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
<span>使用 Google 登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-dialog {
|
||||
--bg-deep: #0D0F1A;
|
||||
--bg-card: #151829;
|
||||
--bg-elevated: #1C2035;
|
||||
--accent: #FFD369;
|
||||
--text: #EAEAEA;
|
||||
--text-muted: #6B7280;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 211, 105, 0.1);
|
||||
}
|
||||
</style>
|
||||
8
admin-frontend/src/components/ui/index.ts
Normal file
8
admin-frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as BaseButton } from './BaseButton.vue'
|
||||
export { default as BaseCard } from './BaseCard.vue'
|
||||
export { default as BaseInput } from './BaseInput.vue'
|
||||
export { default as BaseSelect } from './BaseSelect.vue'
|
||||
export { default as BaseTextarea } from './BaseTextarea.vue'
|
||||
export { default as LoadingSpinner } from './LoadingSpinner.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as ConfirmModal } from './ConfirmModal.vue'
|
||||
27
admin-frontend/src/i18n.ts
Normal file
27
admin-frontend/src/i18n.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
const messages = { en, zh }
|
||||
|
||||
function detectLocale(): 'en' | 'zh' {
|
||||
const saved = localStorage.getItem('locale')
|
||||
if (saved === 'en' || saved === 'zh') return saved
|
||||
const lang = navigator.language.toLowerCase()
|
||||
if (lang.startsWith('zh')) return 'zh'
|
||||
return 'en'
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: detectLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
})
|
||||
|
||||
export function setLocale(locale: 'en' | 'zh') {
|
||||
localStorage.setItem('locale', locale)
|
||||
i18n.global.locale.value = locale
|
||||
}
|
||||
|
||||
export default i18n
|
||||
154
admin-frontend/src/locales/en.json
Normal file
154
admin-frontend/src/locales/en.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "DreamWeaver",
|
||||
"navHome": "Home",
|
||||
"navMyStories": "My Stories",
|
||||
"navProfiles": "Profiles",
|
||||
"navUniverses": "Universes",
|
||||
"navAdmin": "Providers Admin"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "Weave magical",
|
||||
"heroTitleHighlight": "bedtime stories for your child",
|
||||
"heroSubtitle": "AI-powered personalized stories for children aged 3-8, making every bedtime magical",
|
||||
"heroCta": "Start Creating",
|
||||
"heroCtaSecondary": "Learn More",
|
||||
"heroPreviewTitle": "Bunny's Brave Adventure",
|
||||
"heroPreviewText": "In a forest kissed by morning dew, there lived a little white bunny named Cotton...",
|
||||
|
||||
"trustStoriesCreated": "Stories Created",
|
||||
"trustFamilies": "Families Trust Us",
|
||||
"trustSatisfaction": "Satisfaction",
|
||||
|
||||
"featuresTitle": "Why Choose DreamWeaver",
|
||||
"featuresSubtitle": "We combine AI technology with educational principles to create unique stories for every child",
|
||||
"feature1Title": "AI-Powered Creation",
|
||||
"feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child",
|
||||
"feature2Title": "Personalized Memory",
|
||||
"feature2Desc": "The system remembers your child's preferences and growth, making stories more tailored over time",
|
||||
"feature3Title": "Beautiful AI Illustrations",
|
||||
"feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life",
|
||||
"feature4Title": "Warm Voice Narration",
|
||||
"feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams",
|
||||
"feature5Title": "Educational Themes",
|
||||
"feature5Desc": "Courage, friendship, sharing, honesty... naturally weaving positive values into stories",
|
||||
"feature6Title": "Story Universe",
|
||||
"feature6Desc": "Create your own world where beloved characters continue their adventures across stories",
|
||||
|
||||
"howItWorksTitle": "How It Works",
|
||||
"howItWorksSubtitle": "Four steps to start your magical story journey",
|
||||
"step1Title": "Enter Ideas",
|
||||
"step1Desc": "Input keywords, characters, or simple ideas",
|
||||
"step2Title": "AI Creates",
|
||||
"step2Desc": "AI generates a unique story based on your input",
|
||||
"step3Title": "Enrich Content",
|
||||
"step3Desc": "Auto-generate beautiful illustrations and audio",
|
||||
"step4Title": "Share Stories",
|
||||
"step4Desc": "Save and tell stories to your child anytime",
|
||||
|
||||
"showcaseTitle": "Designed for Parents",
|
||||
"showcaseSubtitle": "Simple to use, powerful features",
|
||||
"showcaseFeature1": "Intuitive interface, generate stories in seconds",
|
||||
"showcaseFeature2": "Multi-child profile management with separate memories",
|
||||
"showcaseFeature3": "Story history saved forever, revisit precious moments",
|
||||
"showcaseFeature4": "Bilingual support to nurture language skills",
|
||||
|
||||
"testimonialsTitle": "What Parents Say",
|
||||
"testimonialsSubtitle": "Real feedback from our users",
|
||||
"testimonial1Text": "Every night before bed, my daughter wants a new story. DreamWeaver saves me from making up stories, and the quality is amazing!",
|
||||
"testimonial1Name": "Sarah M.",
|
||||
"testimonial1Role": "Parent of 5-year-old girl",
|
||||
"testimonial2Text": "The personalization is incredible! It remembers my son loves dinosaurs and space, and every story hits his interests perfectly.",
|
||||
"testimonial2Name": "Michael T.",
|
||||
"testimonial2Role": "Parent of 6-year-old boy",
|
||||
"testimonial3Text": "The voice narration is fantastic! Even when traveling, I can tell stories remotely. The voice is warm and natural, my daughter loves it.",
|
||||
"testimonial3Name": "Jennifer L.",
|
||||
"testimonial3Role": "Parent of 4-year-old girl",
|
||||
|
||||
"faqTitle": "Frequently Asked Questions",
|
||||
"faq1Question": "What age is DreamWeaver suitable for?",
|
||||
"faq1Answer": "We're designed for children aged 3-8. Story content, language difficulty, and educational themes are all optimized for this age group.",
|
||||
"faq2Question": "Are the generated stories safe?",
|
||||
"faq2Answer": "Absolutely safe. All stories go through content filtering to ensure they're appropriate for children and convey positive values.",
|
||||
"faq3Question": "Can I customize story characters?",
|
||||
"faq3Answer": "Yes! You can set preferences in your child's profile, or specify character names and traits when creating. AI will incorporate them into the story.",
|
||||
"faq4Question": "Will stories repeat?",
|
||||
"faq4Answer": "No. Every story is originally generated by AI in real-time. Even with the same keywords, you'll get different stories each time.",
|
||||
"faq5Question": "What languages are supported?",
|
||||
"faq5Answer": "Currently we support Chinese and English. You can switch interface language anytime, and stories will adjust accordingly.",
|
||||
|
||||
"ctaTitle": "Ready to Create Magic for Your Child?",
|
||||
"ctaSubtitle": "Start now and let AI weave unique stories for your child's growth",
|
||||
"ctaButton": "Start Creating Free",
|
||||
"ctaNote": "No credit card required",
|
||||
|
||||
"createModalTitle": "Create New Story",
|
||||
"inputTypeKeywords": "Keywords",
|
||||
"inputTypeStory": "Polish Story",
|
||||
"selectProfile": "Select Child Profile",
|
||||
"selectProfileOptional": "(Optional)",
|
||||
"selectUniverse": "Select Story Universe",
|
||||
"noProfile": "No profile",
|
||||
"noUniverse": "No universe",
|
||||
"noUniverseHint": "No universe for this profile yet. Create one in Story Universe.",
|
||||
"inputLabel": "Enter Keywords",
|
||||
"inputLabelStory": "Enter Your Story",
|
||||
"inputPlaceholder": "e.g., bunny, forest, courage, friendship...",
|
||||
"inputPlaceholderStory": "Enter the story you want to polish...",
|
||||
"themeLabel": "Select Educational Theme",
|
||||
"themeOptional": "(Optional)",
|
||||
"themeCourage": "Courage",
|
||||
"themeFriendship": "Friendship",
|
||||
"themeSharing": "Sharing",
|
||||
"themeHonesty": "Honesty",
|
||||
"themePersistence": "Persistence",
|
||||
"themeTolerance": "Tolerance",
|
||||
"themeCustom": "Or custom...",
|
||||
"errorEmpty": "Please enter content",
|
||||
"errorLogin": "Please login first",
|
||||
"generating": "Weaving your story...",
|
||||
"loginFirst": "Please Login",
|
||||
"startCreate": "Create Magic Story"
|
||||
},
|
||||
"stories": {
|
||||
"myStories": "My Stories",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Are you sure to delete this story?",
|
||||
"noStories": "No stories yet."
|
||||
},
|
||||
"storyDetail": {
|
||||
"back": "Back",
|
||||
"generateImage": "Generate Cover",
|
||||
"playAudio": "Play Audio",
|
||||
"modeGenerated": "Generated",
|
||||
"modeEnhanced": "Enhanced"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Provider Management",
|
||||
"reload": "Reload Cache",
|
||||
"create": "Create",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"clear": "Clear",
|
||||
"delete": "Delete",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"adapter": "Adapter",
|
||||
"model": "Model",
|
||||
"apiBase": "API Base",
|
||||
"timeout": "Timeout (ms)",
|
||||
"retries": "Max Retries",
|
||||
"weight": "Weight",
|
||||
"priority": "Priority",
|
||||
"configRef": "Config Ref",
|
||||
"enabled": "Enabled",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"common": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
154
admin-frontend/src/locales/zh.json
Normal file
154
admin-frontend/src/locales/zh.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "梦语织机",
|
||||
"navHome": "首页",
|
||||
"navMyStories": "我的故事",
|
||||
"navProfiles": "孩子档案",
|
||||
"navUniverses": "故事宇宙",
|
||||
"navAdmin": "供应商管理"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "为孩子编织",
|
||||
"heroTitleHighlight": "专属的童话梦境",
|
||||
"heroSubtitle": "AI 智能创作个性化成长故事,陪伴 3-8 岁孩子的每一个美好夜晚",
|
||||
"heroCta": "开始创作",
|
||||
"heroCtaSecondary": "了解更多",
|
||||
"heroPreviewTitle": "小兔子的勇气冒险",
|
||||
"heroPreviewText": "在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔...",
|
||||
|
||||
"trustStoriesCreated": "故事已创作",
|
||||
"trustFamilies": "家庭信赖",
|
||||
"trustSatisfaction": "满意度",
|
||||
|
||||
"featuresTitle": "为什么选择梦语织机",
|
||||
"featuresSubtitle": "我们用 AI 技术和教育理念,为每个孩子打造独一无二的成长故事",
|
||||
"feature1Title": "AI 智能创作",
|
||||
"feature1Desc": "输入几个关键词,AI 即刻为您的孩子创作一个充满想象力的原创故事",
|
||||
"feature2Title": "个性化记忆",
|
||||
"feature2Desc": "系统记住孩子的喜好和成长轨迹,故事越来越懂 TA",
|
||||
"feature3Title": "精美 AI 插画",
|
||||
"feature3Desc": "为每个故事自动生成独特的精美封面插画,让故事更加生动",
|
||||
"feature4Title": "温暖语音朗读",
|
||||
"feature4Desc": "专业级 AI 配音,温暖的声音陪伴孩子进入甜美梦乡",
|
||||
"feature5Title": "教育主题融入",
|
||||
"feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观",
|
||||
"feature6Title": "故事宇宙",
|
||||
"feature6Desc": "创建专属世界观,让喜爱的角色在不同故事中持续冒险",
|
||||
|
||||
"howItWorksTitle": "如何使用",
|
||||
"howItWorksSubtitle": "四步开启奇妙故事之旅",
|
||||
"step1Title": "输入灵感",
|
||||
"step1Desc": "输入关键词、角色或简单想法",
|
||||
"step2Title": "AI 创作",
|
||||
"step2Desc": "AI 根据输入生成专属故事",
|
||||
"step3Title": "丰富内容",
|
||||
"step3Desc": "自动生成精美插画和语音",
|
||||
"step4Title": "分享故事",
|
||||
"step4Desc": "保存收藏,随时为孩子讲述",
|
||||
|
||||
"showcaseTitle": "专为家长设计",
|
||||
"showcaseSubtitle": "简单易用,功能强大",
|
||||
"showcaseFeature1": "直观的创作界面,几秒即可生成故事",
|
||||
"showcaseFeature2": "多孩子档案管理,每个孩子独立记忆",
|
||||
"showcaseFeature3": "故事历史永久保存,随时回顾美好时光",
|
||||
"showcaseFeature4": "支持中英双语,培养语言能力",
|
||||
|
||||
"testimonialsTitle": "家长们怎么说",
|
||||
"testimonialsSubtitle": "来自真实用户的反馈",
|
||||
"testimonial1Text": "每晚睡前,女儿都要听一个新故事。梦语织机让我不再为编故事发愁,而且故事质量真的很高!",
|
||||
"testimonial1Name": "小雨妈妈",
|
||||
"testimonial1Role": "5岁女孩家长",
|
||||
"testimonial2Text": "最惊喜的是个性化功能,系统记住了儿子喜欢恐龙和太空,每个故事都能戳中他的兴趣点。",
|
||||
"testimonial2Name": "航航爸爸",
|
||||
"testimonial2Role": "6岁男孩家长",
|
||||
"testimonial3Text": "语音朗读功能太棒了!出差时也能远程给孩子讲故事,声音温暖自然,孩子很喜欢。",
|
||||
"testimonial3Name": "朵朵妈妈",
|
||||
"testimonial3Role": "4岁女孩家长",
|
||||
|
||||
"faqTitle": "常见问题",
|
||||
"faq1Question": "梦语织机适合多大的孩子?",
|
||||
"faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。",
|
||||
"faq2Question": "生成的故事安全吗?",
|
||||
"faq2Answer": "绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。",
|
||||
"faq3Question": "可以自定义故事角色吗?",
|
||||
"faq3Answer": "可以!您可以在孩子档案中设置喜好,或在创作时指定角色名称、特点,AI 会将其融入故事。",
|
||||
"faq4Question": "故事会重复吗?",
|
||||
"faq4Answer": "不会。每个故事都是 AI 实时原创生成的,即使使用相同关键词,也会产生不同的故事。",
|
||||
"faq5Question": "支持哪些语言?",
|
||||
"faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。",
|
||||
|
||||
"ctaTitle": "准备好为孩子创造魔法了吗?",
|
||||
"ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事",
|
||||
"ctaButton": "免费开始创作",
|
||||
"ctaNote": "无需信用卡,立即体验",
|
||||
|
||||
"createModalTitle": "创作新故事",
|
||||
"inputTypeKeywords": "关键词创作",
|
||||
"inputTypeStory": "故事润色",
|
||||
"selectProfile": "选择孩子档案",
|
||||
"selectProfileOptional": "(可选)",
|
||||
"selectUniverse": "选择故事宇宙",
|
||||
"noProfile": "不使用档案",
|
||||
"noUniverse": "不选择宇宙",
|
||||
"noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建",
|
||||
"inputLabel": "输入关键词",
|
||||
"inputLabelStory": "输入您的故事",
|
||||
"inputPlaceholder": "例如:小兔子, 森林, 勇气, 友谊...",
|
||||
"inputPlaceholderStory": "在这里输入您想要润色的故事...",
|
||||
"themeLabel": "选择教育主题",
|
||||
"themeOptional": "(可选)",
|
||||
"themeCourage": "勇气",
|
||||
"themeFriendship": "友谊",
|
||||
"themeSharing": "分享",
|
||||
"themeHonesty": "诚实",
|
||||
"themePersistence": "坚持",
|
||||
"themeTolerance": "包容",
|
||||
"themeCustom": "或自定义...",
|
||||
"errorEmpty": "请输入内容",
|
||||
"errorLogin": "请先登录",
|
||||
"generating": "正在编织故事...",
|
||||
"loginFirst": "请先登录",
|
||||
"startCreate": "开始创作魔法故事"
|
||||
},
|
||||
"stories": {
|
||||
"myStories": "我的故事",
|
||||
"view": "查看",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "确定删除这个故事吗?",
|
||||
"noStories": "暂无故事。"
|
||||
},
|
||||
"storyDetail": {
|
||||
"back": "返回",
|
||||
"generateImage": "生成封面",
|
||||
"playAudio": "播放音频",
|
||||
"modeGenerated": "生成",
|
||||
"modeEnhanced": "润色"
|
||||
},
|
||||
"admin": {
|
||||
"title": "供应商管理",
|
||||
"reload": "重载缓存",
|
||||
"create": "创建",
|
||||
"edit": "编辑",
|
||||
"save": "保存",
|
||||
"clear": "清空",
|
||||
"delete": "删除",
|
||||
"name": "名称",
|
||||
"type": "类型",
|
||||
"adapter": "适配器",
|
||||
"model": "模型",
|
||||
"apiBase": "API Base",
|
||||
"timeout": "超时 (ms)",
|
||||
"retries": "最大重试",
|
||||
"weight": "权重",
|
||||
"priority": "优先级",
|
||||
"configRef": "Config Ref",
|
||||
"enabled": "启用",
|
||||
"actions": "操作"
|
||||
},
|
||||
"common": {
|
||||
"enabled": "启用",
|
||||
"disabled": "停用",
|
||||
"confirm": "确认",
|
||||
"cancel": "取消"
|
||||
}
|
||||
}
|
||||
14
admin-frontend/src/main.ts
Normal file
14
admin-frontend/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import i18n from './i18n'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
59
admin-frontend/src/router.ts
Normal file
59
admin-frontend/src/router.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/console/providers',
|
||||
},
|
||||
{
|
||||
path: '/my-stories',
|
||||
name: 'my-stories',
|
||||
component: () => import('./views/MyStories.vue'),
|
||||
},
|
||||
{
|
||||
path: '/profiles',
|
||||
name: 'profiles',
|
||||
component: () => import('./views/ChildProfiles.vue'),
|
||||
},
|
||||
{
|
||||
path: '/profiles/:id',
|
||||
name: 'profile-detail',
|
||||
component: () => import('./views/ChildProfileDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/profiles/:id/timeline',
|
||||
name: 'profile-timeline',
|
||||
component: () => import('./views/ChildProfileTimeline.vue'),
|
||||
},
|
||||
{
|
||||
path: '/universes',
|
||||
name: 'universes',
|
||||
component: () => import('./views/Universes.vue'),
|
||||
},
|
||||
{
|
||||
path: '/universes/:id',
|
||||
name: 'universe-detail',
|
||||
component: () => import('./views/UniverseDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/story/:id',
|
||||
name: 'story-detail',
|
||||
component: () => import('./views/StoryDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/storybook/view',
|
||||
name: 'storybook-viewer',
|
||||
component: () => import('./views/StorybookViewer.vue'),
|
||||
},
|
||||
{
|
||||
path: '/console/providers',
|
||||
name: 'admin-providers',
|
||||
component: () => import('./views/AdminProviders.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
38
admin-frontend/src/stores/storybook.ts
Normal file
38
admin-frontend/src/stores/storybook.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface StorybookPage {
|
||||
page_number: number
|
||||
text: string
|
||||
image_prompt: string
|
||||
image_url?: string
|
||||
}
|
||||
|
||||
export interface Storybook {
|
||||
id?: number // 新增
|
||||
title: string
|
||||
main_character: string
|
||||
art_style: string
|
||||
pages: StorybookPage[]
|
||||
cover_prompt: string
|
||||
cover_url?: string
|
||||
}
|
||||
|
||||
export const useStorybookStore = defineStore('storybook', () => {
|
||||
const currentStorybook = ref<Storybook | null>(null)
|
||||
|
||||
function setStorybook(storybook: Storybook) {
|
||||
currentStorybook.value = storybook
|
||||
}
|
||||
|
||||
function clearStorybook() {
|
||||
currentStorybook.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
currentStorybook,
|
||||
setStorybook,
|
||||
clearStorybook,
|
||||
}
|
||||
})
|
||||
49
admin-frontend/src/stores/user.ts
Normal file
49
admin-frontend/src/stores/user.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
avatar_url: string | null
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchSession() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ user: User | null }>('/auth/session')
|
||||
user.value = data.user
|
||||
} catch {
|
||||
user.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loginWithGithub() {
|
||||
window.location.href = '/auth/github/signin'
|
||||
}
|
||||
|
||||
function loginWithGoogle() {
|
||||
window.location.href = '/auth/google/signin'
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.post('/auth/signout')
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
fetchSession,
|
||||
loginWithGithub,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
243
admin-frontend/src/style.css
Normal file
243
admin-frontend/src/style.css
Normal file
@@ -0,0 +1,243 @@
|
||||
/* 引入霞鹜文楷 */
|
||||
@import url('https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-web/style.css');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 全局基础样式 */
|
||||
body {
|
||||
/* 优先使用文楷,营造书卷气 */
|
||||
font-family: 'LXGW WenKai Screen', 'Noto Sans SC', system-ui, sans-serif;
|
||||
|
||||
/* 米色纸张背景 */
|
||||
background-color: #FDFBF7;
|
||||
color: #292524; /* Stone-800 暖炭黑 */
|
||||
|
||||
/* 细微的纹理 (可选) */
|
||||
background-image: radial-gradient(#E5E7EB 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.dark body {
|
||||
background-color: #1C1917; /* Stone-900 */
|
||||
background-image: radial-gradient(#292524 1px, transparent 1px);
|
||||
color: #E7E5E4; /* Stone-200 */
|
||||
}
|
||||
|
||||
/* 自定义滚动条 - 更加柔和 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #D6D3D1; /* Stone-300 */
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #A8A29E; /* Stone-400 */
|
||||
}
|
||||
|
||||
/* 卡片风格 - 实体书签感 */
|
||||
.glass {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E7E5E4; /* Stone-200 */
|
||||
border-radius: 1rem; /* 16px */
|
||||
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 4px -1px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
background-color: #292524;
|
||||
border-color: #44403C;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 标题风格 - 不再使用渐变,而是强调字重和颜色 */
|
||||
.gradient-text {
|
||||
background: none;
|
||||
-webkit-background-clip: unset;
|
||||
-webkit-text-fill-color: initial;
|
||||
color: #1F2937; /* Gray-800 */
|
||||
font-weight: 800; /* ExtraBold */
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.dark .gradient-text {
|
||||
color: #F3F4F6;
|
||||
}
|
||||
|
||||
/* 按钮 - 暖色调琥珀色 */
|
||||
.btn-magic {
|
||||
background-color: #F59E0B; /* Amber-500 */
|
||||
color: #FFFFFF;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.btn-magic:hover {
|
||||
background-color: #D97706; /* Amber-600 */
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.btn-magic:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 卡片悬浮 */
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025);
|
||||
border-color: #F59E0B; /* 悬浮时边框变黄 */
|
||||
}
|
||||
|
||||
/* 输入框 - 极简白卡纸风格 */
|
||||
.input-magic {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E7E5E4; /* Stone-200 */
|
||||
color: #292524;
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input-magic:focus {
|
||||
outline: none;
|
||||
border-color: #F59E0B; /* Amber-500 */
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.dark .input-magic {
|
||||
background: #292524;
|
||||
border-color: #44403C;
|
||||
color: #E7E5E4;
|
||||
}
|
||||
|
||||
/* 装饰背景 - 移除复杂的动画光斑,保持干净 */
|
||||
.bg-pattern {
|
||||
background: none;
|
||||
}
|
||||
|
||||
|
||||
.dark body {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #111827 35%, #1f2937 70%, #111827 100%);
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #c084fc, #f472b6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #a855f7, #ec4899);
|
||||
}
|
||||
|
||||
/* 玻璃态效果 */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
/* 渐变文字 */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn-magic {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
background-size: 200% 200%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-magic:hover {
|
||||
background-position: 100% 100%;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-magic:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dark .btn-magic {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #4c1d95 50%, #a855f7 100%);
|
||||
}
|
||||
|
||||
/* 卡片悬浮效果 */
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 输入框聚焦效果 */
|
||||
.input-magic {
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
background: linear-gradient(white, white) padding-box,
|
||||
linear-gradient(135deg, #e9d5ff, #fbcfe8, #bfdbfe) border-box;
|
||||
}
|
||||
|
||||
.input-magic:focus {
|
||||
background: linear-gradient(white, white) padding-box,
|
||||
linear-gradient(135deg, #a855f7, #ec4899, #3b82f6) border-box;
|
||||
box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.1);
|
||||
}
|
||||
|
||||
.dark .input-magic {
|
||||
background: linear-gradient(#0f172a, #0f172a) padding-box,
|
||||
linear-gradient(135deg, #7c3aed, #db2777, #2563eb) border-box;
|
||||
}
|
||||
|
||||
/* 装饰性背景 */
|
||||
.bg-pattern {
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 80%, rgba(168, 85, 247, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(59, 130, 246, 0.05) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.dark .bg-pattern {
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 80%, rgba(124, 58, 237, 0.18) 0%, transparent 55%),
|
||||
radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.18) 0%, transparent 55%),
|
||||
radial-gradient(circle at 40% 40%, rgba(59, 130, 246, 0.12) 0%, transparent 55%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-float, .card-hover, .btn-magic {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
475
admin-frontend/src/views/AdminProviders.vue
Normal file
475
admin-frontend/src/views/AdminProviders.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
<BaseCard v-if="!isLoggedIn" class="max-w-md mx-auto mt-20" padding="lg">
|
||||
<h1 class="text-3xl font-bold gradient-text mb-4 text-center">DreamWeaver 控制台</h1>
|
||||
<p class="text-sm text-gray-500 mb-8 text-center">请登录以管理 AI 计算引擎与策略</p>
|
||||
<form @submit.prevent="login" class="space-y-6">
|
||||
<BaseInput v-model="loginForm.username" label="管理员账号" required placeholder="admin" />
|
||||
<BaseInput v-model="loginForm.password" label="密钥密码" type="password" required />
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<span v-if="loginError" class="text-sm text-red-500 font-medium">{{ loginError }}</span>
|
||||
<BaseButton type="submit" class="w-full">进入控制台</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseCard>
|
||||
|
||||
<div v-else class="space-y-8">
|
||||
<!-- Header -->
|
||||
<header class="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold gradient-text">引擎调度中心</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Provider Orchestration & Strategy</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
系统运行中
|
||||
</div>
|
||||
<BaseButton variant="ghost" @click="logout" size="sm">退出</BaseButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
|
||||
<!-- Left: Status & Defaults -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<BaseCard padding="md" title="出厂默认策略 (.env)">
|
||||
<div class="space-y-4">
|
||||
<div v-for="(providers, type) in defaults" :key="type" class="p-3 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ type }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="p in providers"
|
||||
:key="p"
|
||||
@click="cloneDefault(type, p)"
|
||||
class="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 font-mono hover:border-indigo-300 hover:text-indigo-600 hover:shadow-sm transition-all cursor-pointer"
|
||||
title="点击基于此默认配置创建"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2 px-1">
|
||||
* 当数据库中未配置或全部禁用时,系统将回退到上述出厂设置。
|
||||
</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard padding="md" title="可用驱动 (Adapters)">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="adapter in availableAdapters" :key="adapter"
|
||||
class="px-2 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-100">
|
||||
{{ adapter.split(':')[1] }} <span class="opacity-50 text-[10px]">({{ adapter.split(':')[0] }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
|
||||
<!-- Right: Active Manager -->
|
||||
<div class="lg:col-span-3 space-y-6">
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex space-x-1 bg-gray-100 p-1 rounded-xl w-fit">
|
||||
<button
|
||||
v-for="tab in ['text', 'image', 'tts', 'storybook']"
|
||||
:key="tab"
|
||||
@click="activeTab = tab"
|
||||
class="px-6 py-2 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
:class="activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
>
|
||||
{{ tab.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Provider Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Add New Card -->
|
||||
<button @click="openCreateModal" class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-gray-200 rounded-2xl hover:border-indigo-300 hover:bg-indigo-50 transition-all group min-h-[200px]">
|
||||
<div class="w-12 h-12 rounded-full bg-white shadow-sm flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
|
||||
<svg class="w-6 h-6 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-600 group-hover:text-indigo-600">添加新的 {{ activeTab }} 引擎</span>
|
||||
</button>
|
||||
|
||||
<!-- Existing Cards -->
|
||||
<div v-for="p in filteredProviders" :key="p.id"
|
||||
class="relative p-6 bg-white rounded-2xl border transition-all duration-200 group hover:shadow-lg"
|
||||
:class="p.enabled ? 'border-gray-200' : 'border-gray-100 opacity-75 bg-gray-50'"
|
||||
>
|
||||
<!-- Enabled Toggle -->
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="font-bold text-lg text-gray-900">{{ p.name }}</h3>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-mono bg-gray-100 text-gray-600 border border-gray-200">
|
||||
{{ p.adapter }}
|
||||
</span>
|
||||
<span v-if="p.model" class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600 border border-blue-100 truncate max-w-[120px]">
|
||||
{{ p.model }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-gray-500 mb-6">
|
||||
<div class="flex justify-between">
|
||||
<span>Priority:</span>
|
||||
<span class="font-medium text-gray-700">{{ p.priority }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>API Key:</span>
|
||||
<span :class="p.has_api_key ? 'text-green-600' : 'text-yellow-600'">
|
||||
{{ p.has_api_key ? '● Configured' : '○ Not Set (Env?)' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-4 border-t border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<BaseButton size="sm" variant="secondary" class="flex-1" @click="edit(p)">配置</BaseButton>
|
||||
<BaseButton size="sm" variant="ghost" class="text-red-500 hover:bg-red-50" @click="remove(p)">删除</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edit/Create Modal -->
|
||||
<div v-if="editing" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="reset">
|
||||
<BaseCard class="w-full max-w-2xl max-h-[90vh] overflow-y-auto" padding="lg" @click.stop>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold gradient-text">{{ form.id ? '编辑引擎配置' : '添加新引擎' }}</h2>
|
||||
<button @click="reset" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<BaseInput v-model="form.name" label="名称 (显示名)" placeholder="如: Pro GPT-4" required />
|
||||
|
||||
<BaseSelect
|
||||
v-model="form.adapter"
|
||||
label="驱动程序 (Adapter)"
|
||||
:options="adapterOptions"
|
||||
required
|
||||
description="选择底层的 API 驱动协议"
|
||||
/>
|
||||
|
||||
<BaseInput v-model="form.model" label="模型名称 (Model)" placeholder="如: gpt-4o, minimax-v2" description="具体调用的模型ID" />
|
||||
|
||||
<BaseInput v-model.number="form.priority" label="优先级 (0-100)" type="number" description="数字越大越优先" />
|
||||
|
||||
<div class="md:col-span-2 p-4 bg-gray-50 rounded-xl border border-gray-100 space-y-4">
|
||||
<h3 class="text-sm font-bold text-gray-700">密钥与连接</h3>
|
||||
<BaseInput v-model="form.api_key" label="API Key" type="password" placeholder="留空则使用 .env 配置" :required="!form.id && !form.config_ref" />
|
||||
<BaseInput v-model="form.api_base" label="API Endpoint / Group ID" placeholder="https://... 或 Group ID" />
|
||||
<BaseInput v-model="form.config_ref" label="Fallback Env Var" placeholder="如: OPENAI_API_KEY (高级)" />
|
||||
</div>
|
||||
|
||||
<!-- MiniMax Specific Config -->
|
||||
<div v-if="form.adapter === 'minimax'" class="md:col-span-2 p-4 bg-indigo-50 rounded-xl border border-indigo-100 space-y-4">
|
||||
<h3 class="text-sm font-bold text-indigo-700">MiniMax 语音参数</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseInput v-model="form.config_json.voice_id" label="音色 ID (Voice ID)" placeholder="male-qn-qingse" />
|
||||
<BaseInput v-model="form.config_json.emotion" label="情感 (Emotion)" placeholder="happy / sad / angry" />
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs font-semibold text-gray-600">语速 (Speed)</label>
|
||||
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.speed || 1.0 }}x</span>
|
||||
</div>
|
||||
<input type="range" v-model.number="form.config_json.speed" min="0.5" max="2.0" step="0.1" class="w-full accent-indigo-600" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs font-semibold text-gray-600">音量 (Volume)</label>
|
||||
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.vol || 1.0 }}</span>
|
||||
</div>
|
||||
<input type="range" v-model.number="form.config_json.vol" min="0.1" max="5.0" step="0.1" class="w-full accent-indigo-600" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs font-semibold text-gray-600">音高 (Pitch)</label>
|
||||
<span class="text-xs text-indigo-600 font-mono">{{ form.config_json.pitch || 0 }}</span>
|
||||
</div>
|
||||
<input type="range" v-model.number="form.config_json.pitch" min="-12" max="12" step="1" class="w-full accent-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CQTAI Specific Config -->
|
||||
<div v-if="form.adapter === 'cqtai'" class="md:col-span-2 p-4 bg-purple-50 rounded-xl border border-purple-100 space-y-4">
|
||||
<h3 class="text-sm font-bold text-purple-700">CQTAI 画图参数</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseSelect
|
||||
v-model="form.config_json.aspect_ratio"
|
||||
label="画面比例 (Aspect Ratio)"
|
||||
:options="[
|
||||
{ value: '1:1', label: '1:1 (正方形)' },
|
||||
{ value: '16:9', label: '16:9 (横屏)' },
|
||||
{ value: '9:16', label: '9:16 (竖屏)' },
|
||||
{ value: '4:3', label: '4:3 (传统)' },
|
||||
{ value: '3:4', label: '3:4 (海报)' }
|
||||
]"
|
||||
placeholder="默认 1:1"
|
||||
/>
|
||||
|
||||
<BaseSelect
|
||||
v-model="form.config_json.resolution"
|
||||
label="分辨率 (Resolution)"
|
||||
:options="[
|
||||
{ value: '1K', label: '1K (标准)' },
|
||||
{ value: '2K', label: '2K (高清)' },
|
||||
{ value: '4K', label: '4K (超清)' }
|
||||
]"
|
||||
placeholder="默认 1K"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antigravity Specific Config -->
|
||||
<div v-if="form.adapter === 'antigravity'" class="md:col-span-2 p-4 bg-emerald-50 rounded-xl border border-emerald-100 space-y-4">
|
||||
<h3 class="text-sm font-bold text-emerald-700">Antigravity 画图参数 (OpenAI Compatible)</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseSelect
|
||||
v-model="form.config_json.size"
|
||||
label="图像尺寸 (Size)"
|
||||
:options="[
|
||||
{ value: '1024x1024', label: '1024x1024 (1:1 正方形)' },
|
||||
{ value: '1280x720', label: '1280x720 (16:9 横屏)' },
|
||||
{ value: '720x1280', label: '720x1280 (9:16 竖屏)' },
|
||||
{ value: '1216x896', label: '1216x896 (4:3 传统)' }
|
||||
]"
|
||||
placeholder="默认 1024x1024"
|
||||
/>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-xs text-emerald-600">
|
||||
💡 使用 Gemini 3 Pro Image 模型,通过 OpenAI 兼容接口生成图像
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 flex justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<BaseButton variant="secondary" type="button" @click="reset">取消</BaseButton>
|
||||
<BaseButton type="submit">{{ form.id ? '保存变更' : '立即创建' }}</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import BaseInput from '../components/ui/BaseInput.vue'
|
||||
import BaseSelect from '../components/ui/BaseSelect.vue'
|
||||
|
||||
// Types
|
||||
type Provider = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
adapter: string
|
||||
model?: string
|
||||
api_base?: string
|
||||
has_api_key?: boolean
|
||||
priority: number
|
||||
enabled: boolean
|
||||
config_ref?: string
|
||||
weight?: number
|
||||
timeout_ms?: number
|
||||
max_retries?: number
|
||||
config_json: Record<string, any>
|
||||
}
|
||||
|
||||
// State
|
||||
// State
|
||||
const loginForm = ref({ username: '', password: '' })
|
||||
const loginError = ref('')
|
||||
const isLoggedIn = ref(!!sessionStorage.getItem('admin_auth'))
|
||||
const activeTab = ref('text')
|
||||
const providers = ref<Provider[]>([])
|
||||
const defaults = ref<Record<string, string[]>>({})
|
||||
const availableAdapters = ref<string[]>([])
|
||||
const editing = ref(false)
|
||||
const form = ref<Partial<Provider> & { api_key?: string; config_json: Record<string, any> }>({
|
||||
type: 'text',
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
config_json: {}
|
||||
})
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
function getAuthHeader(): string {
|
||||
return sessionStorage.getItem('admin_auth') || ''
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function login() {
|
||||
loginError.value = ''
|
||||
const auth = 'Basic ' + btoa(loginForm.value.username + ':' + loginForm.value.password)
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/admin/providers`, {
|
||||
headers: { Authorization: auth },
|
||||
})
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem('admin_auth', auth)
|
||||
isLoggedIn.value = true
|
||||
// 触发 NavBar 更新(如果在同一页面)- 实际上我们需要强制刷新或使用事件总线,但最简单的是 reload
|
||||
// 不过 AdminProviders 是视图组件,NavBar 是布局组件,它们状态不共享是个问题
|
||||
// 鉴于 NavBar 也依赖 sessionStorage 但不响应,这里的页面刷新是必要的
|
||||
window.location.reload()
|
||||
} else {
|
||||
loginError.value = '鉴权失败'
|
||||
}
|
||||
} catch {
|
||||
loginError.value = '网络不可达'
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
sessionStorage.removeItem('admin_auth')
|
||||
isLoggedIn.value = false
|
||||
providers.value = []
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!isLoggedIn.value) return
|
||||
|
||||
const headers = { Authorization: getAuthHeader() }
|
||||
|
||||
// Parallel fetch
|
||||
const [pRes, dRes, aRes] = await Promise.all([
|
||||
fetch(`${apiBase}/admin/providers`, { headers }),
|
||||
fetch(`${apiBase}/admin/providers/defaults`, { headers }),
|
||||
fetch(`${apiBase}/admin/providers/adapters`, { headers })
|
||||
])
|
||||
|
||||
if (pRes.ok) providers.value = await pRes.json()
|
||||
if (dRes.ok) defaults.value = await dRes.json()
|
||||
if (aRes.ok) availableAdapters.value = await aRes.json()
|
||||
}
|
||||
|
||||
// Computed
|
||||
const filteredProviders = computed(() => {
|
||||
return providers.value
|
||||
.filter(p => p.type === activeTab.value)
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
})
|
||||
|
||||
const adapterOptions = computed(() => {
|
||||
return availableAdapters.value
|
||||
.filter(a => a.startsWith(activeTab.value + ':'))
|
||||
.map(a => {
|
||||
const name = a.split(':')[1]
|
||||
return { value: name, label: name } // e.g. 'gemini', 'openai'
|
||||
})
|
||||
})
|
||||
|
||||
// UI Actions
|
||||
function cloneDefault(type: string, name: string) {
|
||||
// 根据默认名称推断配置
|
||||
// 大多数默认 provider 的 name 就是 adapter name (如 gemini, openai, cqtai)
|
||||
// 如果未来有别名,这里可以做映射
|
||||
activeTab.value = type // 切换到对应 tab
|
||||
form.value = {
|
||||
type: type,
|
||||
name: name,
|
||||
adapter: name, // Default assumption
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
weight: 1,
|
||||
timeout_ms: 60000,
|
||||
max_retries: 1,
|
||||
config_json: {}
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
form.value = {
|
||||
type: activeTab.value,
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
weight: 1,
|
||||
timeout_ms: 60000,
|
||||
max_retries: 1,
|
||||
config_json: {}
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function edit(p: Provider) {
|
||||
const { has_api_key, ...rest } = p
|
||||
form.value = { ...rest, api_key: '', config_json: rest.config_json || {} } // Clear key for security, user re-enters if needed
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
editing.value = false
|
||||
form.value = { config_json: {} }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const method = form.value.id ? 'PUT' : 'POST'
|
||||
const url = form.value.id
|
||||
? `${apiBase}/admin/providers/${form.value.id}`
|
||||
: `${apiBase}/admin/providers`
|
||||
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: getAuthHeader()
|
||||
},
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
|
||||
await loadData()
|
||||
reset()
|
||||
}
|
||||
|
||||
async function remove(p: Provider) {
|
||||
if(!confirm(`确认删除 ${p.name}?`)) return
|
||||
await fetch(`${apiBase}/admin/providers/${p.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: getAuthHeader() }
|
||||
})
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function toggleEnabled(p: Provider) {
|
||||
await fetch(`${apiBase}/admin/providers/${p.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: getAuthHeader()
|
||||
},
|
||||
body: JSON.stringify({ enabled: !p.enabled })
|
||||
})
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isLoggedIn.value) loadData()
|
||||
})
|
||||
</script>
|
||||
181
admin-frontend/src/views/ChildProfileDetail.vue
Normal file
181
admin-frontend/src/views/ChildProfileDetail.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import BaseInput from '../components/ui/BaseInput.vue'
|
||||
import BaseSelect from '../components/ui/BaseSelect.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import { ExclamationCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
interface ChildProfile {
|
||||
id: string
|
||||
name: string
|
||||
avatar_url: string | null
|
||||
birth_date: string | null
|
||||
gender: string | null
|
||||
age: number | null
|
||||
interests: string[]
|
||||
growth_themes: string[]
|
||||
stories_count: number
|
||||
total_reading_time: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const profile = ref<ChildProfile | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
birth_date: '',
|
||||
gender: '',
|
||||
interests: '',
|
||||
growth_themes: '',
|
||||
})
|
||||
|
||||
function parseTags(input: string) {
|
||||
return input
|
||||
.split(/[,,]/)
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function fillForm(data: ChildProfile) {
|
||||
form.value = {
|
||||
name: data.name,
|
||||
birth_date: data.birth_date || '',
|
||||
gender: data.gender || '',
|
||||
interests: data.interests.join('、'),
|
||||
growth_themes: data.growth_themes.join('、'),
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.get<ChildProfile>(`/api/profiles/${route.params.id}`)
|
||||
profile.value = data
|
||||
fillForm(data)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile() {
|
||||
if (!form.value.name.trim()) {
|
||||
error.value = '姓名不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.put<ChildProfile>(`/api/profiles/${route.params.id}`, {
|
||||
name: form.value.name.trim(),
|
||||
birth_date: form.value.birth_date || undefined,
|
||||
gender: form.value.gender || undefined,
|
||||
interests: parseTags(form.value.interests),
|
||||
growth_themes: parseTags(form.value.growth_themes),
|
||||
})
|
||||
profile.value = data
|
||||
fillForm(data)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '更新失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProfile() {
|
||||
if (!window.confirm('确定删除这个档案吗?')) return
|
||||
|
||||
try {
|
||||
await api.delete(`/api/profiles/${route.params.id}`)
|
||||
router.push('/profiles')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProfile)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold gradient-text mb-2">档案详情</h1>
|
||||
<p class="text-gray-500">查看并编辑孩子档案</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<BaseButton as="router-link" :to="`/profiles/${route.params.id}/timeline`" class="bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all">
|
||||
✨ 查看成长足迹
|
||||
</BaseButton>
|
||||
<BaseButton as="router-link" to="/profiles" variant="ghost" class="text-purple-600">
|
||||
返回列表
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-10">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="py-10">
|
||||
<EmptyState
|
||||
:icon="ExclamationCircleIcon"
|
||||
title="加载失败"
|
||||
:description="error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="profile" class="space-y-6">
|
||||
<BaseCard>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-purple-400 to-pink-400 text-white font-bold flex items-center justify-center">
|
||||
{{ profile.name.charAt(0) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold text-gray-800">{{ profile.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ profile.age ?? '未知' }} 岁 · {{ profile.gender ?? '未设置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<UserCircleIcon class="h-5 w-5 text-purple-500" />
|
||||
<h2 class="text-lg font-semibold text-gray-700">编辑信息</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseInput v-model="form.name" placeholder="孩子姓名" />
|
||||
<BaseInput v-model="form.birth_date" type="date" />
|
||||
<BaseSelect
|
||||
v-model="form.gender"
|
||||
:options="[
|
||||
{ value: '', label: '性别(可选)' },
|
||||
{ value: 'male', label: '男' },
|
||||
{ value: 'female', label: '女' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]"
|
||||
/>
|
||||
<BaseInput v-model="form.interests" placeholder="兴趣标签(逗号分隔)" />
|
||||
<BaseInput v-model="form.growth_themes" placeholder="成长主题(逗号分隔)" class="md:col-span-2" />
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span v-if="error" class="text-sm text-red-500">{{ error }}</span>
|
||||
<div class="flex gap-3">
|
||||
<BaseButton @click="updateProfile">保存</BaseButton>
|
||||
<BaseButton variant="secondary" class="text-red-500 border-red-200" @click="deleteProfile">
|
||||
删除档案
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
221
admin-frontend/src/views/ChildProfileTimeline.vue
Normal file
221
admin-frontend/src/views/ChildProfileTimeline.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import {
|
||||
SparklesIcon,
|
||||
BookOpenIcon,
|
||||
TrophyIcon,
|
||||
FlagIcon,
|
||||
CalendarIcon,
|
||||
ChevronLeftIcon,
|
||||
ExclamationCircleIcon
|
||||
} from '@heroicons/vue/24/solid'
|
||||
|
||||
interface TimelineEvent {
|
||||
date: string
|
||||
type: 'story' | 'achievement' | 'milestone'
|
||||
title: string
|
||||
description: string | null
|
||||
image_url: string | null
|
||||
metadata: {
|
||||
story_id?: number
|
||||
mode?: string
|
||||
[key: string]: any
|
||||
} | null
|
||||
}
|
||||
|
||||
interface TimelineResponse {
|
||||
events: TimelineEvent[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const events = ref<TimelineEvent[]>([])
|
||||
const profileId = route.params.id as string
|
||||
const profileName = ref('') // We might need to fetch profile details separately or store it
|
||||
|
||||
function getIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'milestone': return FlagIcon
|
||||
case 'story': return BookOpenIcon
|
||||
case 'achievement': return TrophyIcon
|
||||
default: return SparklesIcon
|
||||
}
|
||||
}
|
||||
|
||||
function getColor(type: string) {
|
||||
switch (type) {
|
||||
case 'milestone': return 'text-blue-500'
|
||||
case 'story': return 'text-purple-500'
|
||||
case 'achievement': return 'text-yellow-500'
|
||||
default: return 'text-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(isoStr: string) {
|
||||
const date = new Date(isoStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchTimeline() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Ideally we should also fetch profile basic info here or if the timeline endpoint included it
|
||||
// For now, let's just fetch timeline.
|
||||
// Wait, let's fetch profile first to get the name
|
||||
const profile = await api.get<any>(`/api/profiles/${profileId}`)
|
||||
profileName.value = profile.name
|
||||
|
||||
const data = await api.get<TimelineResponse>(`/api/profiles/${profileId}/timeline`)
|
||||
events.value = data.events
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEventClick(event: TimelineEvent) {
|
||||
if (event.type === 'story' && event.metadata?.story_id) {
|
||||
// Check mode
|
||||
if (event.metadata.mode === 'storybook') {
|
||||
// 这里的逻辑有点复杂,因为目前 storybook viewer 是读 Store 的。
|
||||
// 如果要持久化查看,需要修改 Viewer 支持从 ID 加载。
|
||||
// 暂时先只支持跳转到普通故事详情,或者给出提示
|
||||
// TODO: Viewer support loading by ID
|
||||
router.push(`/story/${event.metadata.story_id}`)
|
||||
} else {
|
||||
router.push(`/story/${event.metadata.story_id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTimeline)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-50 relative overflow-x-hidden font-sans">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute inset-0 z-0 pointer-events-none">
|
||||
<div class="fixed top-0 right-0 w-96 h-96 bg-purple-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
|
||||
<div class="fixed bottom-0 left-0 w-96 h-96 bg-pink-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<div class="relative z-10 max-w-4xl mx-auto px-4 py-8">
|
||||
<BaseButton as="router-link" :to="`/profiles/${profileId}`" variant="secondary" class="mb-8 flex w-32 items-center justify-center gap-2 shadow-sm hover:shadow-md transition-shadow">
|
||||
<ChevronLeftIcon class="h-4 w-4" /> 返回档案
|
||||
</BaseButton>
|
||||
|
||||
<div v-if="loading" class="py-20">
|
||||
<LoadingSpinner text="正在追溯时光..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="py-20">
|
||||
<EmptyState
|
||||
:icon="ExclamationCircleIcon"
|
||||
title="出错了"
|
||||
:description="error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="text-center mb-16 animate-fade-in-down">
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold gradient-text mb-4 tracking-tight">成长足迹</h1>
|
||||
<p v-if="profileName" class="text-xl text-gray-600 font-medium">✨ {{ profileName }} 的奇妙冒险之旅 ✨</p>
|
||||
</div>
|
||||
|
||||
<!-- 暂无数据 -->
|
||||
<div v-if="events.length === 0" class="text-center py-20 bg-white/50 backdrop-blur rounded-3xl border border-white">
|
||||
<SparklesIcon class="h-16 w-16 text-purple-300 mx-auto mb-4" />
|
||||
<p class="text-xl text-gray-500">还没有开始冒险呢,快去创作第一个故事吧!</p>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴内容 -->
|
||||
<div v-else class="relative pb-20">
|
||||
<!-- 垂直线 -->
|
||||
<div class="absolute left-4 md:left-1/2 md:transform md:-translate-x-1/2 top-4 bottom-0 w-1 bg-gradient-to-b from-purple-400 via-pink-400 to-blue-400 rounded-full opacity-40"></div>
|
||||
|
||||
<!-- 事件列表 -->
|
||||
<div v-for="(event, index) in events" :key="index"
|
||||
class="mb-12 flex flex-col md:flex-row items-center w-full group relative"
|
||||
:class="index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'"
|
||||
>
|
||||
<!-- 宽度占位 (Desktop) -->
|
||||
<div class="hidden md:block md:w-5/12"></div>
|
||||
|
||||
<!-- 中轴点 -->
|
||||
<div class="z-20 absolute left-2 md:static flex items-center justify-center border-4 border-white shadow-lg shrink-0 group-hover:scale-110 transition-transform duration-300 w-6 h-6 rounded-full md:w-10 md:h-10 bg-white ring-4 ring-purple-100">
|
||||
<component :is="getIcon(event.type)" class="h-3 w-3 md:h-5 md:w-5" :class="getColor(event.type)" />
|
||||
</div>
|
||||
|
||||
<!-- 卡片 -->
|
||||
<div class="w-full pl-12 md:pl-0 md:w-5/12 order-1" :class="index % 2 === 0 ? 'md:text-right md:pr-12' : 'md:text-left md:pl-12'">
|
||||
<div
|
||||
class="bg-white/90 backdrop-blur-md p-6 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/60 transform hover:-translate-y-1 cursor-pointer overflow-hidden relative"
|
||||
@click="handleEventClick(event)"
|
||||
>
|
||||
<!-- 装饰背景 -->
|
||||
<div class="absolute -right-10 -top-10 w-24 h-24 bg-gradient-to-br from-purple-100 to-transparent rounded-full opacity-50"></div>
|
||||
|
||||
<div class="flex items-center gap-2 mb-3 text-sm text-gray-500 font-semibold uppercase tracking-wider" :class="index % 2 === 0 ? 'md:justify-end' : 'md:justify-start'">
|
||||
<CalendarIcon class="h-4 w-4 text-purple-400" />
|
||||
{{ formatDate(event.date) }}
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-2 group-hover:text-purple-600 transition-colors">{{ event.title }}</h3>
|
||||
|
||||
<p v-if="event.description" class="text-gray-600 text-sm leading-relaxed mb-4">{{ event.description }}</p>
|
||||
|
||||
<div v-if="event.image_url" class="relative mt-4 aspect-video rounded-xl overflow-hidden shadow-md group-hover:shadow-lg transition-shadow">
|
||||
<img :src="event.image_url" loading="lazy" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Role Badge -->
|
||||
<div v-if="event.type === 'achievement'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-yellow-50 text-yellow-700 text-xs font-bold border border-yellow-200">
|
||||
<TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gradient-text {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-purple-600 to-pink-600;
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-fade-in-down {
|
||||
animation: fadeInDown 0.8s ease-out;
|
||||
}
|
||||
@keyframes fadeInDown {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
174
admin-frontend/src/views/ChildProfiles.vue
Normal file
174
admin-frontend/src/views/ChildProfiles.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import BaseInput from '../components/ui/BaseInput.vue'
|
||||
import BaseSelect from '../components/ui/BaseSelect.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import { ExclamationCircleIcon, UserGroupIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
interface ChildProfile {
|
||||
id: string
|
||||
name: string
|
||||
avatar_url: string | null
|
||||
birth_date: string | null
|
||||
gender: string | null
|
||||
age: number | null
|
||||
interests: string[]
|
||||
growth_themes: string[]
|
||||
stories_count: number
|
||||
total_reading_time: number
|
||||
}
|
||||
|
||||
interface ProfileListResponse {
|
||||
profiles: ChildProfile[]
|
||||
total: number
|
||||
}
|
||||
|
||||
const profiles = ref<ChildProfile[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
birth_date: '',
|
||||
gender: '',
|
||||
interests: '',
|
||||
growth_themes: '',
|
||||
})
|
||||
|
||||
function parseTags(input: string) {
|
||||
return input
|
||||
.split(/[,,]/)
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function fetchProfiles() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.get<ProfileListResponse>('/api/profiles')
|
||||
profiles.value = data.profiles
|
||||
total.value = data.total
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProfile() {
|
||||
if (!form.value.name.trim()) {
|
||||
error.value = '请输入孩子姓名'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post<ChildProfile>('/api/profiles', {
|
||||
name: form.value.name.trim(),
|
||||
birth_date: form.value.birth_date || undefined,
|
||||
gender: form.value.gender || undefined,
|
||||
interests: parseTags(form.value.interests),
|
||||
growth_themes: parseTags(form.value.growth_themes),
|
||||
})
|
||||
|
||||
form.value = {
|
||||
name: '',
|
||||
birth_date: '',
|
||||
gender: '',
|
||||
interests: '',
|
||||
growth_themes: '',
|
||||
}
|
||||
await fetchProfiles()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '创建失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProfiles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold gradient-text mb-2">孩子档案</h1>
|
||||
<p class="text-gray-500">为每个孩子建立专属档案</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">共 {{ total }} 个档案</div>
|
||||
</div>
|
||||
|
||||
<BaseCard class="mb-8" padding="lg">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-4">创建新档案</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseInput v-model="form.name" placeholder="孩子姓名" />
|
||||
<BaseInput v-model="form.birth_date" type="date" />
|
||||
<BaseSelect
|
||||
v-model="form.gender"
|
||||
:options="[
|
||||
{ value: '', label: '性别(可选)' },
|
||||
{ value: 'male', label: '男' },
|
||||
{ value: 'female', label: '女' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]"
|
||||
/>
|
||||
<BaseInput v-model="form.interests" placeholder="兴趣标签(逗号分隔)" />
|
||||
<BaseInput v-model="form.growth_themes" placeholder="成长主题(逗号分隔)" class="md:col-span-2" />
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span v-if="error" class="text-sm text-red-500">{{ error }}</span>
|
||||
<BaseButton @click="createProfile">创建档案</BaseButton>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<div v-if="loading" class="py-10">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
<div v-else-if="error" class="py-10">
|
||||
<EmptyState
|
||||
:icon="ExclamationCircleIcon"
|
||||
title="加载失败"
|
||||
:description="error"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="profiles.length === 0" class="py-10">
|
||||
<EmptyState
|
||||
:icon="UserGroupIcon"
|
||||
title="暂无档案"
|
||||
description="创建你的第一个孩子档案"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<router-link
|
||||
v-for="profile in profiles"
|
||||
:key="profile.id"
|
||||
:to="`/profiles/${profile.id}`"
|
||||
class="block"
|
||||
>
|
||||
<BaseCard hover>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-purple-400 to-pink-400 text-white font-bold flex items-center justify-center">
|
||||
{{ profile.name.charAt(0) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-800">{{ profile.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ profile.age ?? '未知' }} 岁</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
兴趣:{{ profile.interests.length ? profile.interests.join('、') : '未设置' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
成长主题:{{ profile.growth_themes.length ? profile.growth_themes.join('、') : '未设置' }}
|
||||
</div>
|
||||
</BaseCard>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
476
admin-frontend/src/views/Home.vue
Normal file
476
admin-frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,476 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import LoginDialog from '../components/ui/LoginDialog.vue'
|
||||
import {
|
||||
SparklesIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
UserGroupIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ========== 导航栏状态 ==========
|
||||
const showUserMenu = ref(false)
|
||||
|
||||
function switchLocale(lang: 'en' | 'zh') {
|
||||
locale.value = lang
|
||||
localStorage.setItem('locale', lang)
|
||||
}
|
||||
|
||||
// ========== 登录对话框状态 ==========
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
// ========== 创作入口 ==========
|
||||
// 旧的创作变量已移除,现在只负责跳转
|
||||
function openCreateModal() {
|
||||
if (!userStore.user) {
|
||||
showLoginDialog.value = true
|
||||
return
|
||||
}
|
||||
// 跳转到后台创作
|
||||
router.push({ path: '/my-stories', query: { openCreate: 'true' } })
|
||||
}
|
||||
|
||||
function scrollToFeatures() {
|
||||
document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// ========== 统计数据 (静态模拟) ==========
|
||||
const storiesCount = 10000
|
||||
const familiesCount = 5000
|
||||
const satisfactionCount = 99
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="landing-page min-h-screen flex flex-col">
|
||||
<!-- ========== 导航栏 ========== -->
|
||||
<nav class="sticky top-0 z-50 bg-[#FDFBF7]/90 backdrop-blur-md border-b border-stone-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<router-link to="/" class="text-2xl font-bold tracking-tight text-amber-600 flex items-center gap-2">
|
||||
<SparklesIcon class="w-6 h-6" />
|
||||
<span>梦语织机</span>
|
||||
</router-link>
|
||||
|
||||
<div class="hidden md:flex space-x-8">
|
||||
<a href="#features" class="text-stone-600 hover:text-amber-600 font-medium transition-colors">功能</a>
|
||||
<a href="#how-it-works" class="text-stone-600 hover:text-amber-600 font-medium transition-colors">使用方法</a>
|
||||
<a href="#faq" class="text-stone-600 hover:text-amber-600 font-medium transition-colors">常见问题</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center bg-white rounded-lg border border-stone-200 p-1">
|
||||
<button
|
||||
class="px-2 py-1 text-xs font-medium rounded-md transition-colors"
|
||||
:class="locale === 'en' ? 'bg-stone-100 text-stone-900' : 'text-stone-500 hover:text-stone-900'"
|
||||
@click="switchLocale('en')"
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 text-xs font-medium rounded-md transition-colors"
|
||||
:class="locale === 'zh' ? 'bg-amber-100 text-amber-900' : 'text-stone-500 hover:text-stone-900'"
|
||||
@click="switchLocale('zh')"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="userStore.user">
|
||||
<div class="relative">
|
||||
<button class="flex items-center space-x-2 text-stone-700 hover:text-amber-600 transition-colors" @click="showUserMenu = !showUserMenu">
|
||||
<img
|
||||
v-if="userStore.user.avatar_url"
|
||||
:src="userStore.user.avatar_url"
|
||||
:alt="userStore.user.name"
|
||||
class="w-8 h-8 rounded-full border border-stone-200"
|
||||
/>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center font-bold">
|
||||
{{ userStore.user.name.charAt(0) }}
|
||||
</div>
|
||||
<span class="font-medium hidden sm:inline">{{ userStore.user.name }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showUserMenu" class="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-stone-100 py-1 origin-top-right transform transition-all z-50">
|
||||
<div class="px-4 py-2 border-b border-stone-50">
|
||||
<p class="text-sm text-stone-500">已登录为</p>
|
||||
<p class="text-sm font-medium text-stone-900 truncate">{{ userStore.user.name }}</p>
|
||||
</div>
|
||||
<router-link to="/my-stories" class="block px-4 py-2 text-sm text-stone-700 hover:bg-stone-50">我的故事</router-link>
|
||||
<router-link to="/profiles" class="block px-4 py-2 text-sm text-stone-700 hover:bg-stone-50">孩子档案</router-link>
|
||||
<button @click="userStore.logout(); showUserMenu = false" class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-stone-50 flex items-center gap-2">
|
||||
<ArrowRightOnRectangleIcon class="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<BaseButton size="sm" @click="showLoginDialog = true">登录 / 注册</BaseButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div v-if="showUserMenu" class="fixed inset-0 z-40" @click="showUserMenu = false"></div>
|
||||
|
||||
<!-- ========== Hero Section ========== -->
|
||||
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24 lg:py-32">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 bg-amber-50 text-amber-700 text-sm font-medium rounded-full mb-6">
|
||||
<span>✨</span> 专为 3-8 岁孩子设计的魔法故事机
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-6xl font-bold text-stone-900 mb-6 leading-tight">
|
||||
为孩子编织
|
||||
<span class="text-amber-600">温暖的童年记忆</span>
|
||||
</h1>
|
||||
<p class="text-xl text-stone-600 mb-8 leading-relaxed">
|
||||
每一个孩子都是天生的梦想家。我们用 AI 科技,将天马行空的想象编织成独一无二的有声绘本,陪伴孩子快乐成长。
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<BaseButton size="lg" @click="openCreateModal" class="shadow-xl shadow-amber-200/50">
|
||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||
开始创作故事
|
||||
</BaseButton>
|
||||
<button @click="scrollToFeatures" class="px-6 py-3 rounded-xl font-semibold text-stone-600 bg-white border border-stone-200 hover:border-amber-400 hover:text-amber-700 transition-all shadow-sm">
|
||||
了解更多功能
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Trust Indicators -->
|
||||
<div class="mt-12 flex items-center gap-8 text-stone-500">
|
||||
<div class="flex -space-x-2">
|
||||
<div class="w-8 h-8 rounded-full bg-stone-200 border-2 border-white"></div>
|
||||
<div class="w-8 h-8 rounded-full bg-stone-300 border-2 border-white"></div>
|
||||
<div class="w-8 h-8 rounded-full bg-stone-400 border-2 border-white"></div>
|
||||
<div class="w-8 h-8 rounded-full bg-stone-100 border-2 border-white flex items-center justify-center text-xs font-bold">+2k</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
已有 <span class="font-bold text-stone-800">5,000+</span> 个家庭正在使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Visual -->
|
||||
<div class="relative">
|
||||
<!-- 背景装饰圆 -->
|
||||
<div class="absolute top-0 right-0 w-72 h-72 bg-amber-100 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob"></div>
|
||||
<div class="absolute bottom-0 left-0 w-72 h-72 bg-orange-100 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob animation-delay-2000"></div>
|
||||
|
||||
<!-- Preview Card -->
|
||||
<div class="relative bg-white p-6 rounded-2xl shadow-xl transform rotate-1 hover:rotate-0 transition-transform duration-500 border border-stone-100">
|
||||
<div class="aspect-[4/3] bg-stone-100 rounded-xl mb-4 overflow-hidden relative group">
|
||||
<!-- Placeholder Image -->
|
||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1618331835717-801e976710b2?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80')] bg-cover bg-center opacity-80 group-hover:scale-105 transition-transform duration-700"></div>
|
||||
<div class="absolute bottom-4 right-4 bg-white/90 backdrop-blur px-3 py-1 rounded-full text-xs font-bold text-stone-800 shadow-sm">
|
||||
绘本插画
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-2">小狐狸的第一次探险</h3>
|
||||
<p class="text-stone-500 text-sm leading-relaxed mb-4">
|
||||
这是一个关于勇气和友谊的故事。小狐狸第一次离开家,在森林里遇到了需要帮助的小松鼠...
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<span class="px-2 py-1 bg-amber-50 text-amber-700 text-xs font-bold rounded-lg">勇气</span>
|
||||
<span class="px-2 py-1 bg-green-50 text-green-700 text-xs font-bold rounded-lg">友谊</span>
|
||||
</div>
|
||||
<div class="w-8 h-8 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center">
|
||||
<SparklesIcon class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========== Features Section ========== -->
|
||||
<section id="features" class="py-24 bg-white scroll-mt-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-stone-900 mb-4">为什么选择梦语织机?</h2>
|
||||
<p class="text-lg text-stone-600 max-w-2xl mx-auto">我们不仅仅是在生成故事,更是在为孩子创造一个安全、温暖、富有教育意义的成长空间。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="p-8 rounded-2xl bg-[#FDFBF7] border border-stone-100 hover:shadow-lg transition-shadow">
|
||||
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center mb-6 text-2xl">🎨</div>
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-3">AI 绘本创作</h3>
|
||||
<p class="text-stone-600">根据故事内容自动生成精美插画,让文字活起来,培养孩子的艺术审美。</p>
|
||||
</div>
|
||||
<div class="p-8 rounded-2xl bg-[#FDFBF7] border border-stone-100 hover:shadow-lg transition-shadow">
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center mb-6 text-2xl">🌱</div>
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-3">个性化成长档案</h3>
|
||||
<p class="text-stone-600">为每个孩子定制专属的主角人设,将性格培养和习惯养成融入故事之中。</p>
|
||||
</div>
|
||||
<div class="p-8 rounded-2xl bg-[#FDFBF7] border border-stone-100 hover:shadow-lg transition-shadow">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mb-6 text-2xl">🎙️</div>
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-3">温暖语音陪伴</h3>
|
||||
<p class="text-stone-600">像爸爸妈妈一样的温柔讲述,无论何时何地,都能给孩子最长情的陪伴。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========== How It Works Section ========== -->
|
||||
<section id="how-it-works" class="py-24 bg-[#FDFBF7] scroll-mt-16 relative overflow-hidden">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-stone-200 to-transparent"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-stone-900 mb-4">只需三步,开启奇妙旅程</h2>
|
||||
<p class="text-lg text-stone-600">零门槛操作,让创意的火花瞬间变成精彩的故事。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-12 relative">
|
||||
<!-- Connector Line (Desktop) -->
|
||||
<div class="hidden md:block absolute top-12 left-[16%] right-[16%] h-0.5 bg-stone-200 -z-10"></div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="text-center group">
|
||||
<div class="w-24 h-24 mx-auto bg-white border border-stone-200 rounded-full flex items-center justify-center mb-6 shadow-sm group-hover:scale-110 transition-transform duration-300 relative z-10">
|
||||
<span class="text-4xl">📝</span>
|
||||
<div class="absolute -top-2 -right-2 w-8 h-8 bg-amber-500 text-white rounded-full flex items-center justify-center font-bold border-4 border-[#FDFBF7]">1</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-2">建立档案</h3>
|
||||
<p class="text-stone-600">输入孩子的名字、年龄和兴趣,让故事里的主角就是他自己。</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="text-center group">
|
||||
<div class="w-24 h-24 mx-auto bg-white border border-stone-200 rounded-full flex items-center justify-center mb-6 shadow-sm group-hover:scale-110 transition-transform duration-300 relative z-10">
|
||||
<span class="text-4xl">✨</span>
|
||||
<div class="absolute -top-2 -right-2 w-8 h-8 bg-amber-500 text-white rounded-full flex items-center justify-center font-bold border-4 border-[#FDFBF7]">2</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-2">输入灵感</h3>
|
||||
<p class="text-stone-600">"想做一个关于勇敢的小恐龙的故事",一句话告诉 AI 你的想法。</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="text-center group">
|
||||
<div class="w-24 h-24 mx-auto bg-white border border-stone-200 rounded-full flex items-center justify-center mb-6 shadow-sm group-hover:scale-110 transition-transform duration-300 relative z-10">
|
||||
<span class="text-4xl">📖</span>
|
||||
<div class="absolute -top-2 -right-2 w-8 h-8 bg-amber-500 text-white rounded-full flex items-center justify-center font-bold border-4 border-[#FDFBF7]">3</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-2">生成绘本</h3>
|
||||
<p class="text-stone-600">稍等片刻,一个图文并茂、配有语音的专属绘本就诞生了。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========== Testimonials Section (家长评价) ========== -->
|
||||
<section id="testimonials" class="py-24 bg-white scroll-mt-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-stone-900 mb-4">听听家长们怎么说</h2>
|
||||
<p class="text-lg text-stone-600">超过 5000 个家庭正在使用梦语织机陪伴孩子成长</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- Review 1 -->
|
||||
<div class="p-8 bg-[#FDFBF7] rounded-3xl border border-stone-100 relative">
|
||||
<div class="absolute -top-4 left-8 text-6xl text-amber-200">"</div>
|
||||
<p class="text-stone-700 italic mb-6 relative z-10">
|
||||
自从有了梦语织机,每天晚上的睡前时光都成了我和女儿最期待的时刻。她最喜欢把自己变成故事里的魔法公主,看到她眼里闪着光,我也觉得好幸福。
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-pink-100 flex items-center justify-center text-xl">👩</div>
|
||||
<div>
|
||||
<div class="font-bold text-stone-900">张雨涵妈妈</div>
|
||||
<div class="text-xs text-stone-500">5岁女孩的母亲</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review 2 -->
|
||||
<div class="p-8 bg-[#FDFBF7] rounded-3xl border border-stone-100 relative">
|
||||
<div class="absolute -top-4 left-8 text-6xl text-amber-200">"</div>
|
||||
<p class="text-stone-700 italic mb-6 relative z-10">
|
||||
工作太忙,以前总是没时间给儿子编故事。现在我只需要输入一个想法,AI 就能帮我生成一个完整又富有教育意义的故事,而且声音特别温柔,简直是哄睡神器!
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-xl">👨</div>
|
||||
<div>
|
||||
<div class="font-bold text-stone-900">李强爸爸</div>
|
||||
<div class="text-xs text-stone-500">4岁男孩的父亲</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review 3 -->
|
||||
<div class="p-8 bg-[#FDFBF7] rounded-3xl border border-stone-100 relative">
|
||||
<div class="absolute -top-4 left-8 text-6xl text-amber-200">"</div>
|
||||
<p class="text-stone-700 italic mb-6 relative z-10">
|
||||
作为幼儿园老师,我经常用它来生成针对性的教育故事。比如班里有小朋友不爱刷牙,我就做了一个《牙齿王国的保卫战》,孩子们特别吃这一套!效果满分。
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center text-xl">🧑🏫</div>
|
||||
<div>
|
||||
<div class="font-bold text-stone-900">王老师</div>
|
||||
<div class="text-xs text-stone-500">资深幼教</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========== Story Gallery (精选绘本展) ========== -->
|
||||
<section id="gallery" class="py-24 bg-[#FDFBF7] relative overflow-hidden">
|
||||
<!-- 装饰背景 -->
|
||||
<div class="absolute top-0 inset-x-0 h-px bg-stone-200"></div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div class="flex flex-col md:flex-row items-end justify-between mb-12 gap-6">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-stone-900 mb-2">探索无限可能</h2>
|
||||
<p class="text-stone-600">从奇幻冒险到温馨日常,每一个故事都是独一无二的宝藏</p>
|
||||
</div>
|
||||
<BaseButton class="shrink-0" @click="openCreateModal">我也要创作</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- 滚动展示区 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<!-- Book 1 -->
|
||||
<div class="group cursor-pointer">
|
||||
<div class="aspect-[3/4] rounded-2xl bg-amber-100 mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 relative">
|
||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1535905557558-afc4877a26fc?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80')] bg-cover bg-center group-hover:scale-105 transition-transform duration-700"></div>
|
||||
<div class="absolute bottom-0 inset-x-0 p-4 bg-gradient-to-t from-black/60 to-transparent text-white opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-sm font-bold">阅读故事 →</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-bold text-stone-800 text-lg">魔法书店的奇妙夜</h3>
|
||||
<p class="text-xs text-stone-500">奇幻 • 想象力</p>
|
||||
</div>
|
||||
<!-- Book 2 -->
|
||||
<div class="group cursor-pointer">
|
||||
<div class="aspect-[3/4] rounded-2xl bg-blue-100 mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 relative">
|
||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1459369510627-9efbee1e6051?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80')] bg-cover bg-center group-hover:scale-105 transition-transform duration-700"></div>
|
||||
</div>
|
||||
<h3 class="font-bold text-stone-800 text-lg">小熊的蜂蜜罐</h3>
|
||||
<p class="text-xs text-stone-500">分享 • 友谊</p>
|
||||
</div>
|
||||
<!-- Book 3 -->
|
||||
<div class="group cursor-pointer">
|
||||
<div class="aspect-[3/4] rounded-2xl bg-green-100 mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 relative">
|
||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1503919005314-30d93d07d823?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80')] bg-cover bg-center group-hover:scale-105 transition-transform duration-700"></div>
|
||||
</div>
|
||||
<h3 class="font-bold text-stone-800 text-lg">森林里的音乐会</h3>
|
||||
<p class="text-xs text-stone-500">艺术 • 自在</p>
|
||||
</div>
|
||||
<!-- Book 4 -->
|
||||
<div class="group cursor-pointer">
|
||||
<div class="aspect-[3/4] rounded-2xl bg-purple-100 mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 relative">
|
||||
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1534447677768-be436bb09401?ixlib=rb-4.0.3&auto=format&fit=crop&w=600&q=80')] bg-cover bg-center group-hover:scale-105 transition-transform duration-700"></div>
|
||||
</div>
|
||||
<h3 class="font-bold text-stone-800 text-lg">冲向月球!</h3>
|
||||
<p class="text-xs text-stone-500">科学 • 探索</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========== FAQ Section ========== -->
|
||||
<section id="faq" class="py-24 bg-white scroll-mt-16">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-stone-900 mb-4">常见问题解答</h2>
|
||||
<p class="text-lg text-stone-600">这里是关于使用梦语织机的一些详细解答</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- FAQ 1: Customization -->
|
||||
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
|
||||
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
|
||||
我可以把孩子设为故事主角吗?
|
||||
<span class="transform group-open:rotate-180 transition-transform">▼</span>
|
||||
</summary>
|
||||
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
|
||||
当然可以!这是我们最核心的功能。您可以在"孩子档案"中设置孩子的名字、年龄、性格特点,AI 会根据这些信息量身定制故事,让孩子在故事中看到自己的影子,代入感极强。
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- FAQ 2: Voice -->
|
||||
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
|
||||
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
|
||||
生成的故事有语音朗读吗?
|
||||
<span class="transform group-open:rotate-180 transition-transform">▼</span>
|
||||
</summary>
|
||||
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
|
||||
是的,我们采用最先进的 TTS(文本转语音)技术,能够生成媲美真人的情感语音。您可以选择不同的讲述人音色(如温柔妈妈、磁性爸爸等),让故事听起来生动有趣。
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- FAQ 3: Education -->
|
||||
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
|
||||
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
|
||||
可以设定特定的教育目标吗?比如"如果不爱吃蔬菜"?
|
||||
<span class="transform group-open:rotate-180 transition-transform">▼</span>
|
||||
</summary>
|
||||
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
|
||||
没问题!在创作故事时,您可以选择或者自定义"教育主题"。例如输入"教孩子为什么要吃蔬菜"或者"如何克服怕黑的心理",AI 会巧妙地将这些道理融入有趣的剧情中,避免生硬的说教。
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- FAQ 4: Download/Print -->
|
||||
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
|
||||
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
|
||||
生成的绘本可以下载打印吗?
|
||||
<span class="transform group-open:rotate-180 transition-transform">▼</span>
|
||||
</summary>
|
||||
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
|
||||
支持。对于会员用户,我们提供高清 PDF 导出功能。您可以将绘本下载并打印出来,装订成独一无二的实体书,成为孩子珍贵的成长纪念。
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- FAQ 5: Safety -->
|
||||
<details class="group bg-[#FDFBF7] rounded-2xl border border-stone-100 overflow-hidden">
|
||||
<summary class="flex items-center justify-between p-6 cursor-pointer font-bold text-stone-800 text-lg select-none hover:bg-stone-50 transition-colors">
|
||||
故事内容对孩子安全吗?
|
||||
<span class="transform group-open:rotate-180 transition-transform">▼</span>
|
||||
</summary>
|
||||
<div class="px-6 pb-6 text-stone-600 leading-relaxed border-t border-stone-100 pt-4 bg-white">
|
||||
安全是我们最重视的原则。我们的 AI 模型经过严格训练和多重内容过滤,确保输出的内容阳光、积极,绝不包含恐怖、暴力或成人向内容,您可以放心给孩子使用。
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-stone-50 border-t border-stone-200 py-12 mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 text-center text-stone-500 text-sm">
|
||||
<p>© 2024 DreamWeaver AI. 用爱编织每一个梦想。</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<LoginDialog v-model="showLoginDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Custom animations if needed */
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
</style>
|
||||
189
admin-frontend/src/views/MyStories.vue
Normal file
189
admin-frontend/src/views/MyStories.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import CreateStoryModal from '../components/CreateStoryModal.vue'
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChevronRightIcon,
|
||||
ExclamationCircleIcon,
|
||||
PhotoIcon,
|
||||
SparklesIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
interface StoryItem {
|
||||
id: number
|
||||
title: string
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const stories = ref<StoryItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
|
||||
async function fetchStories() {
|
||||
try {
|
||||
stories.value = await api.get<StoryItem[]>('/api/stories')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) return '今天'
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days} 天前`
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStories()
|
||||
if (router.currentRoute.value.query.openCreate) {
|
||||
showCreateModal.value = true
|
||||
router.replace({ query: { ...router.currentRoute.value.query, openCreate: undefined } })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold gradient-text mb-2">我的故事</h1>
|
||||
<p class="text-gray-500">收藏的所有童话故事</p>
|
||||
</div>
|
||||
<BaseButton @click="goToCreate">
|
||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||
创作新故事
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="py-20">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="py-10">
|
||||
<EmptyState
|
||||
:icon="ExclamationCircleIcon"
|
||||
title="加载失败"
|
||||
:description="error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="stories.length === 0" class="py-10">
|
||||
<EmptyState
|
||||
:icon="BookOpenIcon"
|
||||
title="开始你的创作之旅"
|
||||
description="还没有创作任何故事,现在就开始为孩子创作第一个专属童话故事吧!"
|
||||
>
|
||||
<template #action>
|
||||
<BaseButton @click="goToCreate">
|
||||
<PlusIcon class="h-5 w-5 mr-2" />
|
||||
创作第一个故事
|
||||
</BaseButton>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<!-- 故事列表 -->
|
||||
<template v-else>
|
||||
<!-- 统计卡片 -->
|
||||
<BaseCard class="mb-8" padding="lg">
|
||||
<div class="flex items-center justify-around divide-x divide-gray-100">
|
||||
<div class="text-center px-4">
|
||||
<div class="text-3xl font-bold text-gray-800">{{ stories.length }}</div>
|
||||
<div class="text-gray-500 text-sm mt-1">故事总数</div>
|
||||
</div>
|
||||
<div class="text-center px-4">
|
||||
<div class="text-3xl font-bold text-gray-800">
|
||||
{{ stories.filter(s => s.image_url).length }}
|
||||
</div>
|
||||
<div class="text-gray-500 text-sm mt-1">已配图</div>
|
||||
</div>
|
||||
<div class="text-center px-4">
|
||||
<BookOpenIcon class="h-8 w-8 text-purple-500 mx-auto" />
|
||||
<div class="text-gray-500 text-sm mt-1">继续阅读</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- 故事网格 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<router-link
|
||||
v-for="story in stories"
|
||||
:key="story.id"
|
||||
:to="`/story/${story.id}`"
|
||||
class="block group"
|
||||
>
|
||||
<BaseCard hover padding="none" class="h-full overflow-hidden flex flex-col">
|
||||
<!-- 封面图 -->
|
||||
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
|
||||
<img
|
||||
v-if="story.image_url"
|
||||
:src="story.image_url"
|
||||
:alt="story.title"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-gray-300"
|
||||
>
|
||||
<PhotoIcon class="h-12 w-12" />
|
||||
</div>
|
||||
|
||||
<!-- 悬停阅读提示 -->
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<span class="inline-flex items-center gap-1 px-4 py-2 bg-white/90 text-gray-900 rounded-full font-medium shadow-lg backdrop-blur-sm transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
|
||||
阅读故事 <ChevronRightIcon class="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息区 -->
|
||||
<div class="p-5 flex-1 flex flex-col">
|
||||
<h3 class="font-bold text-xl text-gray-800 mb-2 line-clamp-2 group-hover:text-purple-600 transition-colors">
|
||||
{{ story.title }}
|
||||
</h3>
|
||||
<div class="mt-auto flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{{ formatDate(story.created_at) }}</span>
|
||||
<span v-if="story.image_url" class="flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded text-xs font-medium">
|
||||
已配图
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<CreateStoryModal v-model="showCreateModal" />
|
||||
</div>
|
||||
</template>
|
||||
312
admin-frontend/src/views/StoryDetail.vue
Normal file
312
admin-frontend/src/views/StoryDetail.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import ConfirmModal from '../components/ui/ConfirmModal.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PhotoIcon,
|
||||
SpeakerWaveIcon,
|
||||
SparklesIcon,
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
interface Story {
|
||||
id: number
|
||||
title: string
|
||||
story_text: string
|
||||
cover_prompt: string | null
|
||||
image_url: string | null
|
||||
mode: string
|
||||
}
|
||||
|
||||
const story = ref<Story | null>(null)
|
||||
const loading = ref(true)
|
||||
const imageLoading = ref(false)
|
||||
const audioLoading = ref(false)
|
||||
const audioUrl = ref<string | null>(null)
|
||||
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const audioProgress = ref(0)
|
||||
const audioDuration = ref(0)
|
||||
const error = ref('')
|
||||
const showDeleteConfirm = ref(false)
|
||||
const imageGenerationFailed = ref(false)
|
||||
|
||||
async function fetchStory() {
|
||||
try {
|
||||
story.value = await api.get<Story>(`/api/stories/${route.params.id}`)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateImage() {
|
||||
if (!story.value) return
|
||||
imageLoading.value = true
|
||||
try {
|
||||
const result = await api.post<{ image_url: string }>(`/api/image/generate/${story.value.id}`)
|
||||
story.value.image_url = result.image_url
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '图片生成失败'
|
||||
} finally {
|
||||
imageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudio() {
|
||||
if (!story.value || audioUrl.value) return
|
||||
audioLoading.value = true
|
||||
try {
|
||||
const response = await fetch(`/api/audio/${story.value.id}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) throw new Error('音频加载失败')
|
||||
const blob = await response.blob()
|
||||
audioUrl.value = URL.createObjectURL(blob)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '音频加载失败'
|
||||
} finally {
|
||||
audioLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!audioRef.value) return
|
||||
if (isPlaying.value) {
|
||||
audioRef.value.pause()
|
||||
} else {
|
||||
audioRef.value.play()
|
||||
}
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
if (!audioRef.value) return
|
||||
audioProgress.value = audioRef.value.currentTime
|
||||
audioDuration.value = audioRef.value.duration || 0
|
||||
}
|
||||
|
||||
function seekAudio(e: MouseEvent) {
|
||||
if (!audioRef.value || !audioDuration.value) return
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
audioRef.value.currentTime = percent * audioDuration.value
|
||||
}
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function deleteStory() {
|
||||
if (!story.value) return
|
||||
try {
|
||||
await api.delete(`/api/stories/${story.value.id}`)
|
||||
router.push('/my-stories')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
showDeleteConfirm.value = false
|
||||
await deleteStory()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStory()
|
||||
if (route.query.imageError === '1') {
|
||||
imageGenerationFailed.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<div v-if="loading" class="py-20">
|
||||
<LoadingSpinner size="lg" text="正在加载故事..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error && !story" class="text-center py-20">
|
||||
<ExclamationTriangleIcon class="h-14 w-14 text-red-400 mx-auto mb-4" />
|
||||
<p class="text-red-500 text-lg mb-6">{{ error }}</p>
|
||||
<BaseButton @click="router.push('/')">返回首页</BaseButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="story" class="space-y-8">
|
||||
<BaseButton variant="ghost" class="w-fit" @click="router.back()">
|
||||
<ArrowLeftIcon class="h-5 w-5" />
|
||||
返回
|
||||
</BaseButton>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<div
|
||||
v-if="imageGenerationFailed && !story?.image_url"
|
||||
class="p-4 bg-amber-50 border border-amber-200 text-amber-700 rounded-xl flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<ExclamationTriangleIcon class="h-5 w-5" />
|
||||
<span>封面生成失败,您可以稍后重试</span>
|
||||
</div>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-amber-500 hover:text-amber-700"
|
||||
@click="imageGenerationFailed = false"
|
||||
>
|
||||
<XMarkIcon class="h-5 w-5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="glass rounded-3xl shadow-2xl overflow-hidden">
|
||||
<div class="relative aspect-[21/9] bg-gradient-to-br from-purple-100 via-pink-100 to-blue-100 overflow-hidden">
|
||||
<img
|
||||
v-if="story.image_url"
|
||||
:src="story.image_url"
|
||||
:alt="story.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<PhotoIcon class="h-16 w-16 text-purple-400 mb-4" />
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
:loading="imageLoading"
|
||||
@click="generateImage"
|
||||
>
|
||||
<template v-if="imageLoading">AI 正在绘制...</template>
|
||||
<template v-else>生成精美封面</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white/80 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div class="p-8 md:p-12 -mt-16 relative">
|
||||
<h1 class="text-3xl md:text-4xl font-bold gradient-text mb-8 leading-tight">
|
||||
{{ story.title }}
|
||||
</h1>
|
||||
|
||||
<div class="prose prose-lg max-w-none mb-10">
|
||||
<p
|
||||
v-for="(paragraph, index) in story.story_text.split('\n\n')"
|
||||
:key="index"
|
||||
class="text-gray-700 leading-loose mb-6 first-letter:text-4xl first-letter:font-bold first-letter:text-purple-600 first-letter:float-left first-letter:mr-2"
|
||||
>
|
||||
{{ paragraph }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-2xl p-6 mb-8">
|
||||
<div v-if="!audioUrl" class="text-center">
|
||||
<BaseButton
|
||||
:loading="audioLoading"
|
||||
@click="loadAudio"
|
||||
class="mx-auto"
|
||||
>
|
||||
<template v-if="audioLoading">加载中...</template>
|
||||
<template v-else>
|
||||
<SpeakerWaveIcon class="h-5 w-5" />
|
||||
听故事
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="audioUrl"
|
||||
@timeupdate="updateProgress"
|
||||
@ended="isPlaying = false"
|
||||
@loadedmetadata="audioDuration = audioRef?.duration || 0"
|
||||
/>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<BaseButton
|
||||
class="w-14 h-14 p-0 rounded-full shadow-lg hover:shadow-xl"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<svg v-if="!isPlaying" class="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</BaseButton>
|
||||
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="h-2 bg-gray-200 rounded-full cursor-pointer overflow-hidden"
|
||||
@click="seekAudio"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-purple-500 to-pink-500 rounded-full transition-all duration-100"
|
||||
:style="{ width: `${(audioProgress / audioDuration) * 100 || 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm text-gray-500 mt-1">
|
||||
<span>{{ formatTime(audioProgress) }}</span>
|
||||
<span>{{ formatTime(audioDuration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<div v-if="error" class="mb-6 p-4 bg-red-50 border border-red-200 text-red-600 rounded-xl">
|
||||
{{ error }}
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="flex items-center justify-between pt-6 border-t border-gray-100">
|
||||
<BaseButton as="router-link" to="/" variant="ghost" class="text-purple-600">
|
||||
<SparklesIcon class="h-5 w-5" />
|
||||
创作新故事
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton variant="ghost" class="text-red-500" @click="showDeleteConfirm = true">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
删除
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
:show="showDeleteConfirm"
|
||||
title="确定删除这个故事吗?"
|
||||
message="删除后将无法恢复"
|
||||
confirm-text="确定删除"
|
||||
cancel-text="取消"
|
||||
variant="danger"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
197
admin-frontend/src/views/StorybookViewer.vue
Normal file
197
admin-frontend/src/views/StorybookViewer.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStorybookStore } from '../stores/storybook'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
HomeIcon,
|
||||
BookOpenIcon,
|
||||
SparklesIcon,
|
||||
PhotoIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useStorybookStore()
|
||||
const storybook = computed(() => store.currentStorybook)
|
||||
|
||||
const currentPageIndex = ref(-1) // -1 for cover
|
||||
|
||||
// 计算属性
|
||||
const totalPages = computed(() => storybook.value?.pages.length || 0)
|
||||
const isCover = computed(() => currentPageIndex.value === -1)
|
||||
const isLastPage = computed(() => currentPageIndex.value === totalPages.value - 1)
|
||||
const currentPage = computed(() => {
|
||||
if (!storybook.value || isCover.value) return null
|
||||
return storybook.value.pages[currentPageIndex.value]
|
||||
})
|
||||
|
||||
// 导航
|
||||
function goHome() {
|
||||
store.clearStorybook()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPageIndex.value < totalPages.value - 1) {
|
||||
currentPageIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPageIndex.value > -1) {
|
||||
currentPageIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!storybook.value) {
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="storybook-viewer" v-if="storybook">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 p-4 flex justify-between items-center bg-gradient-to-b from-black/50 to-transparent">
|
||||
<button @click="goHome" class="p-2 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all">
|
||||
<HomeIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<div class="text-white font-serif text-lg text-shadow">
|
||||
{{ storybook.title }}
|
||||
</div>
|
||||
<div class="w-10"></div> <!-- 占位 -->
|
||||
</nav>
|
||||
|
||||
<!-- 主展示区 -->
|
||||
<div class="h-screen w-full flex items-center justify-center p-4 md:p-8 relative overflow-hidden">
|
||||
<!-- 动态背景 -->
|
||||
<div class="absolute inset-0 bg-[#0D0F1A] z-0">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-[#1a1a2e] to-[#0D0F1A]"></div>
|
||||
<div class="stars"></div>
|
||||
</div>
|
||||
|
||||
<!-- 书页容器 -->
|
||||
<div class="book-container relative z-10 w-full max-w-5xl aspect-[16/10] bg-[#fffbf0] rounded-2xl shadow-2xl overflow-hidden flex transition-all duration-500">
|
||||
|
||||
<!-- 封面模式 -->
|
||||
<div v-if="isCover" class="w-full h-full flex flex-col md:flex-row animate-fade-in">
|
||||
<!-- 封面图 -->
|
||||
<div class="w-full md:w-1/2 h-1/2 md:h-full relative overflow-hidden bg-gray-900 group">
|
||||
<template v-if="storybook.cover_url">
|
||||
<img :src="storybook.cover_url" class="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" />
|
||||
</template>
|
||||
<div v-else class="w-full h-full flex flex-col items-center justify-center p-8 text-center bg-gradient-to-br from-indigo-900 to-purple-900 text-white">
|
||||
<SparklesIcon class="w-20 h-20 mb-4 opacity-50" />
|
||||
<p class="text-white/60 text-sm">封面正在构思中...</p>
|
||||
</div>
|
||||
<!-- 封面遮罩 -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent md:bg-gradient-to-r"></div>
|
||||
<div class="absolute bottom-6 left-6 text-white md:hidden">
|
||||
<span class="inline-block px-3 py-1 bg-yellow-500/90 rounded-full text-xs font-bold mb-2 text-black">绘本故事</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 封面信息 -->
|
||||
<div class="w-full md:w-1/2 h-1/2 md:h-full p-8 md:p-16 flex flex-col justify-center bg-[#fffbf0] text-amber-900">
|
||||
<div class="hidden md:block mb-8">
|
||||
<span class="inline-block px-4 py-1 border border-amber-900/30 rounded-full text-sm tracking-widest uppercase">Original Storybook</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl font-serif font-bold mb-6 leading-tight">{{ storybook.title }}</h1>
|
||||
|
||||
<div class="space-y-4 mb-10 text-amber-900/70">
|
||||
<p class="flex items-center"><span class="w-20 font-bold opacity-50">主角</span> {{ storybook.main_character }}</p>
|
||||
<p class="flex items-center"><span class="w-20 font-bold opacity-50">画风</span> {{ storybook.art_style }}</p>
|
||||
</div>
|
||||
|
||||
<BaseButton size="lg" @click="nextPage" class="self-start shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all">
|
||||
开始阅读 <BookOpenIcon class="w-5 h-5 ml-2" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内页模式 -->
|
||||
<div v-else class="w-full h-full flex flex-col md:flex-row animate-fade-in relative">
|
||||
<!-- 页码 -->
|
||||
<div class="absolute bottom-4 right-6 text-amber-900/30 font-serif text-xl z-20">
|
||||
{{ currentPageIndex + 1 }} / {{ totalPages }}
|
||||
</div>
|
||||
|
||||
<!-- 插图区域 (左) -->
|
||||
<div class="w-full md:w-1/2 h-1/2 md:h-full relative bg-gray-100 border-r border-amber-900/5">
|
||||
<template v-if="currentPage?.image_url">
|
||||
<img :src="currentPage.image_url" class="w-full h-full object-cover" />
|
||||
</template>
|
||||
<div v-else class="w-full h-full flex items-center justify-center p-10 bg-white">
|
||||
<div class="text-center">
|
||||
<div class="inline-block p-6 rounded-full bg-amber-50 mb-4">
|
||||
<PhotoIcon class="w-10 h-10 text-amber-300" />
|
||||
</div>
|
||||
<p class="text-amber-900/40 text-sm max-w-xs mx-auto italic">"{{ currentPage?.image_prompt }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字区域 (右) -->
|
||||
<div class="w-full md:w-1/2 h-1/2 md:h-full p-8 md:p-16 flex items-center justify-center bg-[#fffbf0]">
|
||||
<div class="prose prose-xl prose-amber font-serif text-amber-900 leading-relaxed text-center md:text-left">
|
||||
<p>{{ currentPage?.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 翻页控制 (悬浮) -->
|
||||
<button
|
||||
v-if="!isCover"
|
||||
@click="prevPage"
|
||||
class="fixed left-4 md:left-8 top-1/2 -translate-y-1/2 p-3 md:p-4 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all disabled:opacity-30"
|
||||
>
|
||||
<ArrowLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!isLastPage"
|
||||
@click="nextPage"
|
||||
class="fixed right-4 md:right-8 top-1/2 -translate-y-1/2 p-3 md:p-4 rounded-full bg-white/10 backdrop-blur hover:bg-white/20 text-white transition-all shadow-lg"
|
||||
>
|
||||
<ArrowRightIcon class="w-6 h-6 md:w-8 md:h-8" />
|
||||
</button>
|
||||
|
||||
<!-- 最后一页的完成按钮 -->
|
||||
<BaseButton
|
||||
v-if="isLastPage"
|
||||
@click="goHome"
|
||||
class="fixed right-8 md:right-12 bottom-8 md:bottom-12 shadow-xl"
|
||||
>
|
||||
读完了,再来一本
|
||||
</BaseButton>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-shadow {
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.98); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.book-container {
|
||||
box-shadow:
|
||||
0 20px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
}
|
||||
</style>
|
||||
208
admin-frontend/src/views/UniverseDetail.vue
Normal file
208
admin-frontend/src/views/UniverseDetail.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import BaseInput from '../components/ui/BaseInput.vue'
|
||||
import BaseTextarea from '../components/ui/BaseTextarea.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import { ExclamationCircleIcon, GlobeAltIcon, TrophyIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
interface StoryUniverse {
|
||||
id: string
|
||||
child_profile_id: string
|
||||
name: string
|
||||
protagonist: Record<string, unknown>
|
||||
recurring_characters: Record<string, unknown>[]
|
||||
world_settings: Record<string, unknown>
|
||||
achievements: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const universe = ref<StoryUniverse | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const formError = ref('')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
protagonist: '',
|
||||
recurring_characters: '',
|
||||
world_settings: '',
|
||||
})
|
||||
|
||||
const achievement = ref({
|
||||
type: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
function toJsonString(value: unknown) {
|
||||
return JSON.stringify(value ?? {}, null, 2)
|
||||
}
|
||||
|
||||
function parseJson(input: string, label: string) {
|
||||
try {
|
||||
return JSON.parse(input)
|
||||
} catch (e) {
|
||||
throw new Error(`${label} 需要是合法 JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
function fillForm(data: StoryUniverse) {
|
||||
form.value = {
|
||||
name: data.name,
|
||||
protagonist: toJsonString(data.protagonist),
|
||||
recurring_characters: JSON.stringify(data.recurring_characters ?? [], null, 2),
|
||||
world_settings: toJsonString(data.world_settings),
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUniverse() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.get<StoryUniverse>(`/api/universes/${route.params.id}`)
|
||||
universe.value = data
|
||||
fillForm(data)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUniverse() {
|
||||
formError.value = ''
|
||||
if (!form.value.name.trim()) {
|
||||
formError.value = '名称不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.value.name.trim(),
|
||||
protagonist: parseJson(form.value.protagonist, '主角设定'),
|
||||
recurring_characters: parseJson(form.value.recurring_characters, '常驻角色'),
|
||||
world_settings: parseJson(form.value.world_settings, '世界观'),
|
||||
}
|
||||
const data = await api.put<StoryUniverse>(`/api/universes/${route.params.id}`, payload)
|
||||
universe.value = data
|
||||
fillForm(data)
|
||||
} catch (e) {
|
||||
formError.value = e instanceof Error ? e.message : '更新失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function addAchievement() {
|
||||
if (!achievement.value.type.trim() || !achievement.value.description.trim()) {
|
||||
formError.value = '成就类型和描述不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.post<StoryUniverse>(
|
||||
`/api/universes/${route.params.id}/achievements`,
|
||||
{
|
||||
type: achievement.value.type.trim(),
|
||||
description: achievement.value.description.trim(),
|
||||
},
|
||||
)
|
||||
universe.value = data
|
||||
achievement.value = { type: '', description: '' }
|
||||
} catch (e) {
|
||||
formError.value = e instanceof Error ? e.message : '添加成就失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUniverse() {
|
||||
if (!window.confirm('确定删除这个宇宙吗?')) return
|
||||
|
||||
try {
|
||||
await api.delete(`/api/universes/${route.params.id}`)
|
||||
router.push('/universes')
|
||||
} catch (e) {
|
||||
formError.value = e instanceof Error ? e.message : '删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchUniverse)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold gradient-text mb-2">宇宙详情</h1>
|
||||
<p class="text-gray-500">编辑故事宇宙设定</p>
|
||||
</div>
|
||||
<BaseButton as="router-link" to="/universes" variant="ghost" class="text-purple-600">
|
||||
返回列表
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-10">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="py-10">
|
||||
<EmptyState
|
||||
:icon="ExclamationCircleIcon"
|
||||
title="加载失败"
|
||||
:description="error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="universe" class="space-y-6">
|
||||
<BaseCard>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GlobeAltIcon class="h-5 w-5 text-purple-500" />
|
||||
<h2 class="text-lg font-semibold text-gray-700">宇宙设定</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<BaseInput v-model="form.name" placeholder="宇宙名称" />
|
||||
<BaseTextarea v-model="form.protagonist" :rows="4" placeholder="主角设定 JSON" />
|
||||
<BaseTextarea v-model="form.recurring_characters" :rows="4" placeholder="常驻角色 JSON" />
|
||||
<BaseTextarea v-model="form.world_settings" :rows="4" placeholder="世界观 JSON" />
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span v-if="formError" class="text-sm text-red-500">{{ formError }}</span>
|
||||
<div class="flex gap-3">
|
||||
<BaseButton @click="updateUniverse">保存</BaseButton>
|
||||
<BaseButton variant="secondary" class="text-red-500 border-red-200" @click="deleteUniverse">
|
||||
删除宇宙
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<TrophyIcon class="h-5 w-5 text-purple-500" />
|
||||
<h2 class="text-lg font-semibold text-gray-700">成就管理</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseInput v-model="achievement.type" placeholder="成就类型" />
|
||||
<BaseInput v-model="achievement.description" placeholder="成就描述" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<BaseButton @click="addAchievement">添加成就</BaseButton>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<div v-if="universe.achievements.length === 0" class="text-gray-500">暂无成就</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="(item, index) in universe.achievements"
|
||||
:key="index"
|
||||
class="bg-white/70 border border-white/50 rounded-xl px-4 py-3 text-sm text-gray-600"
|
||||
>
|
||||
{{ (item as any).type }} · {{ (item as any).description }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
203
admin-frontend/src/views/Universes.vue
Normal file
203
admin-frontend/src/views/Universes.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import BaseInput from '../components/ui/BaseInput.vue'
|
||||
import BaseSelect from '../components/ui/BaseSelect.vue'
|
||||
import BaseTextarea from '../components/ui/BaseTextarea.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import { ExclamationCircleIcon, GlobeAltIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
interface ChildProfile {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface StoryUniverse {
|
||||
id: string
|
||||
name: string
|
||||
protagonist: Record<string, unknown>
|
||||
recurring_characters: Record<string, unknown>[]
|
||||
world_settings: Record<string, unknown>
|
||||
achievements: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
interface ProfileListResponse {
|
||||
profiles: ChildProfile[]
|
||||
total: number
|
||||
}
|
||||
|
||||
interface UniverseListResponse {
|
||||
universes: StoryUniverse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
const profiles = ref<ChildProfile[]>([])
|
||||
const universes = ref<StoryUniverse[]>([])
|
||||
const selectedProfileId = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const formError = ref('')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
protagonistName: '',
|
||||
protagonistRole: '',
|
||||
worldDescription: '',
|
||||
})
|
||||
|
||||
async function fetchProfiles() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.get<ProfileListResponse>('/api/profiles')
|
||||
profiles.value = data.profiles
|
||||
if (!selectedProfileId.value && profiles.value.length) {
|
||||
selectedProfileId.value = profiles.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUniverses(profileId: string) {
|
||||
if (!profileId) {
|
||||
universes.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.get<UniverseListResponse>(`/api/profiles/${profileId}/universes`)
|
||||
universes.value = data.universes
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载宇宙失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function createUniverse() {
|
||||
formError.value = ''
|
||||
if (!selectedProfileId.value) {
|
||||
formError.value = '请先选择孩子档案'
|
||||
return
|
||||
}
|
||||
if (!form.value.name.trim()) {
|
||||
formError.value = '宇宙名称不能为空'
|
||||
return
|
||||
}
|
||||
if (!form.value.protagonistName.trim()) {
|
||||
formError.value = '主角姓名不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.value.name.trim(),
|
||||
protagonist: {
|
||||
name: form.value.protagonistName,
|
||||
role: form.value.protagonistRole || '探险家'
|
||||
},
|
||||
recurring_characters: [],
|
||||
world_settings: {
|
||||
description: form.value.worldDescription
|
||||
},
|
||||
}
|
||||
|
||||
await api.post(`/api/profiles/${selectedProfileId.value}/universes`, payload)
|
||||
|
||||
// Reset form
|
||||
form.value.name = ''
|
||||
form.value.protagonistName = ''
|
||||
form.value.protagonistRole = ''
|
||||
form.value.worldDescription = ''
|
||||
|
||||
await fetchUniverses(selectedProfileId.value)
|
||||
} catch (e) {
|
||||
formError.value = e instanceof Error ? e.message : '创建失败'
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedProfileId, (value) => {
|
||||
fetchUniverses(value)
|
||||
})
|
||||
|
||||
onMounted(fetchProfiles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold gradient-text mb-2">故事宇宙</h1>
|
||||
<p class="text-gray-500">维护孩子的故事世界观</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseCard class="mb-8" padding="lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseSelect
|
||||
v-model="selectedProfileId"
|
||||
:options="profiles.map(profile => ({ value: profile.id, label: profile.name }))"
|
||||
placeholder="请选择孩子档案"
|
||||
/>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard class="mb-8" padding="lg">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-4">创建新宇宙</h2>
|
||||
<div class="space-y-4">
|
||||
<BaseInput v-model="form.name" label="宇宙名称" placeholder="给这个世界起个名字" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BaseInput v-model="form.protagonistName" label="主角姓名" placeholder="例如:乐乐" />
|
||||
<BaseInput v-model="form.protagonistRole" label="主角身份" placeholder="例如:小小探险家" />
|
||||
</div>
|
||||
<BaseTextarea
|
||||
v-model="form.worldDescription"
|
||||
label="世界观描述"
|
||||
:rows="3"
|
||||
placeholder="简要描述这个世界的规则,例如:这里的所有动物都会说话,天上有三个月亮..."
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<span v-if="formError" class="text-sm text-red-500">{{ formError }}</span>
|
||||
<div v-else></div> <!-- Spacer -->
|
||||
<BaseButton @click="createUniverse">创建宇宙</BaseButton>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<div v-if="loading" class="py-10">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
<div v-else-if="error" class="py-10">
|
||||
<EmptyState
|
||||
:icon="ExclamationCircleIcon"
|
||||
title="加载失败"
|
||||
:description="error"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="universes.length === 0" class="py-10">
|
||||
<EmptyState
|
||||
:icon="GlobeAltIcon"
|
||||
title="暂无宇宙"
|
||||
description="为孩子创建一个新的故事宇宙"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<router-link
|
||||
v-for="universe in universes"
|
||||
:key="universe.id"
|
||||
:to="`/universes/${universe.id}`"
|
||||
class="block"
|
||||
>
|
||||
<BaseCard hover>
|
||||
<div class="font-semibold text-gray-800">{{ universe.name }}</div>
|
||||
<div class="text-sm text-gray-500 mt-2">主角:{{ (universe.protagonist as any).name || '未设置' }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">成就:{{ universe.achievements?.length || 0 }} 个</div>
|
||||
</BaseCard>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
7
admin-frontend/src/vite-env.d.ts
vendored
Normal file
7
admin-frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
46
admin-frontend/tailwind.config.js
Normal file
46
admin-frontend/tailwind.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', 'sans-serif'],
|
||||
},
|
||||
borderRadius: {
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
},
|
||||
boxShadow: {
|
||||
glass: '0 8px 32px rgba(0,0,0,0.08)',
|
||||
},
|
||||
animation: {
|
||||
float: 'float 3s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0px)' },
|
||||
'50%': { transform: 'translateY(-10px)' },
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#fdf4ff',
|
||||
100: '#fae8ff',
|
||||
200: '#f5d0fe',
|
||||
300: '#f0abfc',
|
||||
400: '#e879f9',
|
||||
500: '#d946ef',
|
||||
600: '#c026d3',
|
||||
700: '#a21caf',
|
||||
800: '#86198f',
|
||||
900: '#701a75',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
24
admin-frontend/tsconfig.json
Normal file
24
admin-frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
admin-frontend/tsconfig.node.json
Normal file
11
admin-frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
29
admin-frontend/vite.config.ts
Normal file
29
admin-frontend/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:52000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://localhost:52000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/admin': {
|
||||
target: 'http://localhost:52800',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user