wip: snapshot full local workspace state
Some checks are pending
Build and Push Docker Images / changes (push) Waiting to run
Build and Push Docker Images / build-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-admin-frontend (push) Blocked by required conditions
Some checks are pending
Build and Push Docker Images / changes (push) Waiting to run
Build and Push Docker Images / build-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-admin-frontend (push) Blocked by required conditions
This commit is contained in:
34
frontend/.gitignore
vendored
34
frontend/.gitignore
vendored
@@ -1,17 +1,17 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -1,23 +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;"]
|
||||
# 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;"]
|
||||
|
||||
@@ -1,13 +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>梦语织机 - AI儿童故事生成器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!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>梦语织机 - AI儿童故事生成器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
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;
|
||||
}
|
||||
|
||||
# 反向代理: 将 /api 请求转发给后端容器
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 静态资源代理 (后端生成的图片)
|
||||
location /static/ {
|
||||
proxy_pass http://backend:8000/static/;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
# 反向代理: 将 /api 请求转发给后端容器
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 静态资源代理 (后端生成的图片)
|
||||
location /static/ {
|
||||
proxy_pass http://backend:8000/static/;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
|
||||
5254
frontend/package-lock.json
generated
5254
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,28 @@
|
||||
{
|
||||
"name": "dreamweaver-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "dreamweaver-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<text y=".9em" font-size="90">✨</text>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<text y=".9em" font-size="90">✨</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 113 B After Width: | Height: | Size: 116 B |
@@ -1,399 +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>
|
||||
<!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>
|
||||
@@ -1,28 +1,28 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -41,5 +41,5 @@ class ApiClient {
|
||||
return this.request<T>(url, { method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient()
|
||||
|
||||
export const api = new ApiClient()
|
||||
|
||||
@@ -1,221 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import BaseButton from './ui/BaseButton.vue'
|
||||
import BaseInput from './ui/BaseInput.vue'
|
||||
import BaseSelect from './ui/BaseSelect.vue'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'submit', data: { type: string; value: Record<string, unknown> }): void
|
||||
}>()
|
||||
|
||||
const selectedType = ref('favorite_character')
|
||||
const loading = ref(false)
|
||||
|
||||
// 喜欢的角色表单
|
||||
const characterForm = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
// 回避元素表单
|
||||
const scaryForm = ref({
|
||||
keyword: '',
|
||||
category: 'other',
|
||||
})
|
||||
|
||||
// 阅读偏好表单
|
||||
const preferenceForm = ref({
|
||||
preference: '',
|
||||
category: '',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'favorite_character', label: '💕 喜欢的角色' },
|
||||
{ value: 'scary_element', label: '⚠️ 回避元素' },
|
||||
{ value: 'reading_preference', label: '📚 阅读偏好' },
|
||||
]
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'creature', label: '生物' },
|
||||
{ value: 'scene', label: '场景' },
|
||||
{ value: 'action', label: '动作' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const isValid = computed(() => {
|
||||
switch (selectedType.value) {
|
||||
case 'favorite_character':
|
||||
return characterForm.value.name.trim().length > 0
|
||||
case 'scary_element':
|
||||
return scaryForm.value.keyword.trim().length > 0
|
||||
case 'reading_preference':
|
||||
return preferenceForm.value.preference.trim().length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
function resetForms() {
|
||||
characterForm.value = { name: '', description: '' }
|
||||
scaryForm.value = { keyword: '', category: 'other' }
|
||||
preferenceForm.value = { preference: '', category: '' }
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForms()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!isValid.value) return
|
||||
|
||||
let value: Record<string, unknown> = {}
|
||||
|
||||
switch (selectedType.value) {
|
||||
case 'favorite_character':
|
||||
value = {
|
||||
name: characterForm.value.name.trim(),
|
||||
description: characterForm.value.description.trim(),
|
||||
}
|
||||
break
|
||||
case 'scary_element':
|
||||
value = {
|
||||
keyword: scaryForm.value.keyword.trim(),
|
||||
category: scaryForm.value.category,
|
||||
}
|
||||
break
|
||||
case 'reading_preference':
|
||||
value = {
|
||||
preference: preferenceForm.value.preference.trim(),
|
||||
category: preferenceForm.value.category.trim(),
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
emit('submit', { type: selectedType.value, value })
|
||||
resetForms()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="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="handleClose"
|
||||
></div>
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 transform transition-all">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">添加记忆</h2>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 类型选择 -->
|
||||
<div class="mb-4">
|
||||
<BaseSelect
|
||||
v-model="selectedType"
|
||||
label="记忆类型"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 喜欢的角色表单 -->
|
||||
<div v-if="selectedType === 'favorite_character'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="characterForm.name"
|
||||
label="角色名称"
|
||||
placeholder="例如:小兔子、勇敢的骑士"
|
||||
required
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="characterForm.description"
|
||||
label="角色描述(可选)"
|
||||
placeholder="简短描述这个角色的特点"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 回避元素表单 -->
|
||||
<div v-if="selectedType === 'scary_element'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="scaryForm.keyword"
|
||||
label="回避的元素"
|
||||
placeholder="例如:大灰狼、黑暗的森林"
|
||||
required
|
||||
/>
|
||||
<BaseSelect
|
||||
v-model="scaryForm.category"
|
||||
label="分类"
|
||||
:options="categoryOptions"
|
||||
/>
|
||||
<p class="text-sm text-gray-500">
|
||||
添加后,生成故事时会自动避免出现这些元素
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 阅读偏好表单 -->
|
||||
<div v-if="selectedType === 'reading_preference'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="preferenceForm.preference"
|
||||
label="偏好内容"
|
||||
placeholder="例如:喜欢冒险故事、喜欢动物主题"
|
||||
required
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="preferenceForm.category"
|
||||
label="偏好类别(可选)"
|
||||
placeholder="例如:题材、风格、长度"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
|
||||
<BaseButton variant="secondary" @click="handleClose">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton :disabled="!isValid || loading" @click="handleSubmit">
|
||||
{{ loading ? '添加中...' : '添加' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from > div:last-child,
|
||||
.modal-leave-to > div:last-child {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import BaseButton from './ui/BaseButton.vue'
|
||||
import BaseInput from './ui/BaseInput.vue'
|
||||
import BaseSelect from './ui/BaseSelect.vue'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'submit', data: { type: string; value: Record<string, unknown> }): void
|
||||
}>()
|
||||
|
||||
const selectedType = ref('favorite_character')
|
||||
const loading = ref(false)
|
||||
|
||||
// 喜欢的角色表单
|
||||
const characterForm = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
// 回避元素表单
|
||||
const scaryForm = ref({
|
||||
keyword: '',
|
||||
category: 'other',
|
||||
})
|
||||
|
||||
// 阅读偏好表单
|
||||
const preferenceForm = ref({
|
||||
preference: '',
|
||||
category: '',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'favorite_character', label: '💕 喜欢的角色' },
|
||||
{ value: 'scary_element', label: '⚠️ 回避元素' },
|
||||
{ value: 'reading_preference', label: '📚 阅读偏好' },
|
||||
]
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'creature', label: '生物' },
|
||||
{ value: 'scene', label: '场景' },
|
||||
{ value: 'action', label: '动作' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const isValid = computed(() => {
|
||||
switch (selectedType.value) {
|
||||
case 'favorite_character':
|
||||
return characterForm.value.name.trim().length > 0
|
||||
case 'scary_element':
|
||||
return scaryForm.value.keyword.trim().length > 0
|
||||
case 'reading_preference':
|
||||
return preferenceForm.value.preference.trim().length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
function resetForms() {
|
||||
characterForm.value = { name: '', description: '' }
|
||||
scaryForm.value = { keyword: '', category: 'other' }
|
||||
preferenceForm.value = { preference: '', category: '' }
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForms()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!isValid.value) return
|
||||
|
||||
let value: Record<string, unknown> = {}
|
||||
|
||||
switch (selectedType.value) {
|
||||
case 'favorite_character':
|
||||
value = {
|
||||
name: characterForm.value.name.trim(),
|
||||
description: characterForm.value.description.trim(),
|
||||
}
|
||||
break
|
||||
case 'scary_element':
|
||||
value = {
|
||||
keyword: scaryForm.value.keyword.trim(),
|
||||
category: scaryForm.value.category,
|
||||
}
|
||||
break
|
||||
case 'reading_preference':
|
||||
value = {
|
||||
preference: preferenceForm.value.preference.trim(),
|
||||
category: preferenceForm.value.category.trim(),
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
emit('submit', { type: selectedType.value, value })
|
||||
resetForms()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="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="handleClose"
|
||||
></div>
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 transform transition-all">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">添加记忆</h2>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 类型选择 -->
|
||||
<div class="mb-4">
|
||||
<BaseSelect
|
||||
v-model="selectedType"
|
||||
label="记忆类型"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 喜欢的角色表单 -->
|
||||
<div v-if="selectedType === 'favorite_character'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="characterForm.name"
|
||||
label="角色名称"
|
||||
placeholder="例如:小兔子、勇敢的骑士"
|
||||
required
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="characterForm.description"
|
||||
label="角色描述(可选)"
|
||||
placeholder="简短描述这个角色的特点"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 回避元素表单 -->
|
||||
<div v-if="selectedType === 'scary_element'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="scaryForm.keyword"
|
||||
label="回避的元素"
|
||||
placeholder="例如:大灰狼、黑暗的森林"
|
||||
required
|
||||
/>
|
||||
<BaseSelect
|
||||
v-model="scaryForm.category"
|
||||
label="分类"
|
||||
:options="categoryOptions"
|
||||
/>
|
||||
<p class="text-sm text-gray-500">
|
||||
添加后,生成故事时会自动避免出现这些元素
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 阅读偏好表单 -->
|
||||
<div v-if="selectedType === 'reading_preference'" class="space-y-4">
|
||||
<BaseInput
|
||||
v-model="preferenceForm.preference"
|
||||
label="偏好内容"
|
||||
placeholder="例如:喜欢冒险故事、喜欢动物主题"
|
||||
required
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="preferenceForm.category"
|
||||
label="偏好类别(可选)"
|
||||
placeholder="例如:题材、风格、长度"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-100">
|
||||
<BaseButton variant="secondary" @click="handleClose">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton :disabled="!isValid || loading" @click="handleSubmit">
|
||||
{{ loading ? '添加中...' : '添加' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from > div:last-child,
|
||||
.modal-leave-to > div:last-child {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,380 +1,377 @@
|
||||
<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 AnalysisAnimation from './ui/AnalysisAnimation.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
|
||||
|
||||
<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 AnalysisAnimation from './ui/AnalysisAnimation.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
|
||||
page_count: 6,
|
||||
child_profile_id: selectedProfileId.value || undefined,
|
||||
universe_id: selectedUniverseId.value || undefined
|
||||
})
|
||||
|
||||
storybookStore.setStorybook(response)
|
||||
close()
|
||||
router.push('/storybook/view')
|
||||
const storybookPath = response.id ? `/storybook/view/${response.id}` : '/storybook/view'
|
||||
router.push(storybookPath)
|
||||
} else {
|
||||
const result = await api.post<any>('/api/stories/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 })
|
||||
router.push(`/story/${result.id}`)
|
||||
}
|
||||
} 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="!loading && close()"
|
||||
></div>
|
||||
|
||||
<!-- 全屏加载动画 -->
|
||||
<AnalysisAnimation v-if="loading" />
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div v-else 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>
|
||||
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="!loading && close()"
|
||||
></div>
|
||||
|
||||
<!-- 全屏加载动画 -->
|
||||
<AnalysisAnimation v-if="loading" />
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div v-else 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>
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
HeartIcon,
|
||||
BookOpenIcon,
|
||||
ExclamationTriangleIcon,
|
||||
AcademicCapIcon,
|
||||
SparklesIcon,
|
||||
StarIcon,
|
||||
TrophyIcon,
|
||||
LightBulbIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string
|
||||
type: string
|
||||
value: Record<string, unknown>
|
||||
base_weight: number
|
||||
ttl_days: number | null
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
memories: MemoryItem[]
|
||||
loading?: boolean
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
showActions: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', id: string): void
|
||||
}>()
|
||||
|
||||
// 记忆类型配置
|
||||
const typeConfig: Record<string, {
|
||||
label: string
|
||||
icon: typeof HeartIcon
|
||||
color: string
|
||||
bgColor: string
|
||||
}> = {
|
||||
recent_story: {
|
||||
label: '近期故事',
|
||||
icon: BookOpenIcon,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
favorite_character: {
|
||||
label: '喜欢的角色',
|
||||
icon: HeartIcon,
|
||||
color: 'text-pink-600',
|
||||
bgColor: 'bg-pink-50',
|
||||
},
|
||||
scary_element: {
|
||||
label: '回避元素',
|
||||
icon: ExclamationTriangleIcon,
|
||||
color: 'text-amber-600',
|
||||
bgColor: 'bg-amber-50',
|
||||
},
|
||||
vocabulary_growth: {
|
||||
label: '词汇积累',
|
||||
icon: AcademicCapIcon,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
emotional_highlight: {
|
||||
label: '情感高光',
|
||||
icon: SparklesIcon,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50',
|
||||
},
|
||||
reading_preference: {
|
||||
label: '阅读偏好',
|
||||
icon: StarIcon,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
},
|
||||
milestone: {
|
||||
label: '里程碑',
|
||||
icon: TrophyIcon,
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50',
|
||||
},
|
||||
skill_mastered: {
|
||||
label: '掌握的技能',
|
||||
icon: LightBulbIcon,
|
||||
color: 'text-teal-600',
|
||||
bgColor: 'bg-teal-50',
|
||||
},
|
||||
}
|
||||
|
||||
// 按类型分组记忆
|
||||
const groupedMemories = computed(() => {
|
||||
const groups: Record<string, MemoryItem[]> = {}
|
||||
|
||||
for (const memory of props.memories) {
|
||||
if (!groups[memory.type]) {
|
||||
groups[memory.type] = []
|
||||
}
|
||||
groups[memory.type].push(memory)
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 获取记忆的显示文本
|
||||
function getMemoryDisplayText(memory: MemoryItem): string {
|
||||
const value = memory.value as Record<string, unknown>
|
||||
|
||||
switch (memory.type) {
|
||||
case 'recent_story':
|
||||
return value.title as string || '未知故事'
|
||||
case 'favorite_character':
|
||||
return `${value.name || '未知角色'}${value.description ? ` - ${value.description}` : ''}`
|
||||
case 'scary_element':
|
||||
return value.keyword as string || '未知元素'
|
||||
case 'vocabulary_growth':
|
||||
return value.word as string || '未知词汇'
|
||||
case 'emotional_highlight':
|
||||
return value.description as string || '情感记忆'
|
||||
case 'reading_preference':
|
||||
return value.preference as string || '阅读偏好'
|
||||
case 'milestone':
|
||||
return value.title as string || '里程碑'
|
||||
case 'skill_mastered':
|
||||
return value.skill as string || '技能'
|
||||
default:
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
if (window.confirm('确定要删除这条记忆吗?')) {
|
||||
emit('delete', id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="memories.length === 0" class="text-center py-8 text-gray-500">
|
||||
<SparklesIcon class="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>还没有记忆记录</p>
|
||||
<p class="text-sm mt-1">阅读故事后会自动积累记忆</p>
|
||||
</div>
|
||||
|
||||
<!-- 记忆列表 -->
|
||||
<div v-else class="space-y-6">
|
||||
<div v-for="(items, type) in groupedMemories" :key="type">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<component
|
||||
:is="typeConfig[type]?.icon || SparklesIcon"
|
||||
:class="['w-5 h-5', typeConfig[type]?.color || 'text-gray-500']"
|
||||
/>
|
||||
<h3 class="font-semibold text-gray-700">
|
||||
{{ typeConfig[type]?.label || type }}
|
||||
</h3>
|
||||
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||
{{ items.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="memory in items"
|
||||
:key="memory.id"
|
||||
:class="[
|
||||
'group relative p-4 rounded-xl border transition-all hover:shadow-md',
|
||||
typeConfig[memory.type]?.bgColor || 'bg-gray-50',
|
||||
'border-gray-100'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-800 truncate">
|
||||
{{ getMemoryDisplayText(memory) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{{ formatDate(memory.created_at) }}
|
||||
<span v-if="memory.ttl_days" class="ml-2">
|
||||
· 有效期 {{ memory.ttl_days }}天
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
v-if="showActions"
|
||||
@click="handleDelete(memory.id)"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500"
|
||||
title="删除"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 权重指示器 -->
|
||||
<div
|
||||
v-if="memory.base_weight > 1"
|
||||
class="absolute top-2 right-2 w-2 h-2 rounded-full bg-yellow-400"
|
||||
title="高权重记忆"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
HeartIcon,
|
||||
BookOpenIcon,
|
||||
ExclamationTriangleIcon,
|
||||
AcademicCapIcon,
|
||||
SparklesIcon,
|
||||
StarIcon,
|
||||
TrophyIcon,
|
||||
LightBulbIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
export interface MemoryItem {
|
||||
id: string
|
||||
type: string
|
||||
value: Record<string, unknown>
|
||||
base_weight: number
|
||||
ttl_days: number | null
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
memories: MemoryItem[]
|
||||
loading?: boolean
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
showActions: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', id: string): void
|
||||
}>()
|
||||
|
||||
// 记忆类型配置
|
||||
const typeConfig: Record<string, {
|
||||
label: string
|
||||
icon: typeof HeartIcon
|
||||
color: string
|
||||
bgColor: string
|
||||
}> = {
|
||||
recent_story: {
|
||||
label: '近期故事',
|
||||
icon: BookOpenIcon,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
favorite_character: {
|
||||
label: '喜欢的角色',
|
||||
icon: HeartIcon,
|
||||
color: 'text-pink-600',
|
||||
bgColor: 'bg-pink-50',
|
||||
},
|
||||
scary_element: {
|
||||
label: '回避元素',
|
||||
icon: ExclamationTriangleIcon,
|
||||
color: 'text-amber-600',
|
||||
bgColor: 'bg-amber-50',
|
||||
},
|
||||
vocabulary_growth: {
|
||||
label: '词汇积累',
|
||||
icon: AcademicCapIcon,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
emotional_highlight: {
|
||||
label: '情感高光',
|
||||
icon: SparklesIcon,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50',
|
||||
},
|
||||
reading_preference: {
|
||||
label: '阅读偏好',
|
||||
icon: StarIcon,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
},
|
||||
milestone: {
|
||||
label: '里程碑',
|
||||
icon: TrophyIcon,
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50',
|
||||
},
|
||||
skill_mastered: {
|
||||
label: '掌握的技能',
|
||||
icon: LightBulbIcon,
|
||||
color: 'text-teal-600',
|
||||
bgColor: 'bg-teal-50',
|
||||
},
|
||||
}
|
||||
|
||||
// 按类型分组记忆
|
||||
const groupedMemories = computed(() => {
|
||||
const groups: Record<string, MemoryItem[]> = {}
|
||||
|
||||
for (const memory of props.memories) {
|
||||
if (!groups[memory.type]) {
|
||||
groups[memory.type] = []
|
||||
}
|
||||
groups[memory.type].push(memory)
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 获取记忆的显示文本
|
||||
function getMemoryDisplayText(memory: MemoryItem): string {
|
||||
const value = memory.value as Record<string, unknown>
|
||||
|
||||
switch (memory.type) {
|
||||
case 'recent_story':
|
||||
return value.title as string || '未知故事'
|
||||
case 'favorite_character':
|
||||
return `${value.name || '未知角色'}${value.description ? ` - ${value.description}` : ''}`
|
||||
case 'scary_element':
|
||||
return value.keyword as string || '未知元素'
|
||||
case 'vocabulary_growth':
|
||||
return value.word as string || '未知词汇'
|
||||
case 'emotional_highlight':
|
||||
return value.description as string || '情感记忆'
|
||||
case 'reading_preference':
|
||||
return value.preference as string || '阅读偏好'
|
||||
case 'milestone':
|
||||
return value.title as string || '里程碑'
|
||||
case 'skill_mastered':
|
||||
return value.skill as string || '技能'
|
||||
default:
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
if (window.confirm('确定要删除这条记忆吗?')) {
|
||||
emit('delete', id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="memories.length === 0" class="text-center py-8 text-gray-500">
|
||||
<SparklesIcon class="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>还没有记忆记录</p>
|
||||
<p class="text-sm mt-1">阅读故事后会自动积累记忆</p>
|
||||
</div>
|
||||
|
||||
<!-- 记忆列表 -->
|
||||
<div v-else class="space-y-6">
|
||||
<div v-for="(items, type) in groupedMemories" :key="type">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<component
|
||||
:is="typeConfig[type]?.icon || SparklesIcon"
|
||||
:class="['w-5 h-5', typeConfig[type]?.color || 'text-gray-500']"
|
||||
/>
|
||||
<h3 class="font-semibold text-gray-700">
|
||||
{{ typeConfig[type]?.label || type }}
|
||||
</h3>
|
||||
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||
{{ items.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="memory in items"
|
||||
:key="memory.id"
|
||||
:class="[
|
||||
'group relative p-4 rounded-xl border transition-all hover:shadow-md',
|
||||
typeConfig[memory.type]?.bgColor || 'bg-gray-50',
|
||||
'border-gray-100'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-800 truncate">
|
||||
{{ getMemoryDisplayText(memory) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{{ formatDate(memory.created_at) }}
|
||||
<span v-if="memory.ttl_days" class="ml-2">
|
||||
· 有效期 {{ memory.ttl_days }}天
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
v-if="showActions"
|
||||
@click="handleDelete(memory.id)"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500"
|
||||
title="删除"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 权重指示器 -->
|
||||
<div
|
||||
v-if="memory.base_weight > 1"
|
||||
class="absolute top-2 right-2 w-2 h-2 rounded-full bg-yellow-400"
|
||||
title="高权重记忆"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,139 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const steps = [
|
||||
'正在接收梦境信号...',
|
||||
'编织故事脉络...',
|
||||
'绘制精美插画 (需要一点点魔法时间)...',
|
||||
'撒上一些星光粉...',
|
||||
'即将完成独一无二的绘本!'
|
||||
]
|
||||
|
||||
const currentStepIndex = ref(0)
|
||||
let stepInterval: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
stepInterval = window.setInterval(() => {
|
||||
if (currentStepIndex.value < steps.length - 1) {
|
||||
currentStepIndex.value++
|
||||
}
|
||||
}, 2500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (stepInterval) clearInterval(stepInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[#1C2035] overflow-hidden">
|
||||
<!-- 背景星空 -->
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div v-for="i in 20" :key="i"
|
||||
class="absolute rounded-full bg-white animate-twinkle"
|
||||
:style="{
|
||||
top: `${Math.random() * 100}%`,
|
||||
left: `${Math.random() * 100}%`,
|
||||
width: `${Math.random() * 3 + 1}px`,
|
||||
height: `${Math.random() * 3 + 1}px`,
|
||||
animationDelay: `${Math.random() * 3}s`,
|
||||
opacity: Math.random() * 0.7 + 0.3
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 核心动画:梦境织机 -->
|
||||
<div class="relative w-64 h-64 mb-12 flex items-center justify-center">
|
||||
<!-- 外圈光晕 -->
|
||||
<div class="absolute inset-0 border-4 border-amber-500/20 rounded-full animate-spin-slow"></div>
|
||||
<div class="absolute inset-2 border-2 border-amber-400/30 rounded-full animate-spin-reverse-slower"></div>
|
||||
|
||||
<!-- 核心光球 -->
|
||||
<div class="relative z-10 w-32 h-32 bg-gradient-to-br from-amber-300 to-orange-500 rounded-full shadow-[0_0_60px_rgba(245,158,11,0.5)] animate-pulse-glow flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-white animate-bounce-gentle" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 飞舞的粒子 -->
|
||||
<div class="absolute inset-0 animate-spin-slow">
|
||||
<div class="absolute top-0 left-1/2 w-3 h-3 bg-amber-200 rounded-full shadow-lg blur-[1px]"></div>
|
||||
<div class="absolute bottom-10 right-10 w-2 h-2 bg-purple-300 rounded-full shadow-lg blur-[1px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字提示 -->
|
||||
<div class="z-10 text-center space-y-4">
|
||||
<h3 class="text-3xl font-bold bg-gradient-to-r from-amber-200 to-orange-100 bg-clip-text text-transparent animate-gradient-x">
|
||||
梦境编织中...
|
||||
</h3>
|
||||
<Transition mode="out-in" name="fade-slide">
|
||||
<p :key="currentStepIndex" class="text-stone-300 text-lg font-medium tracking-wide h-8">
|
||||
{{ steps[currentStepIndex] }}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin-reverse-slower {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 0 40px rgba(245,158,11,0.4); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 0 70px rgba(245,158,11,0.7); }
|
||||
}
|
||||
|
||||
@keyframes bounce-gentle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 0.8; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 12s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse-slower {
|
||||
animation: spin-reverse-slower 20s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-gentle {
|
||||
animation: bounce-gentle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-twinkle {
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const steps = [
|
||||
'正在接收梦境信号...',
|
||||
'编织故事脉络...',
|
||||
'绘制精美插画 (需要一点点魔法时间)...',
|
||||
'撒上一些星光粉...',
|
||||
'即将完成独一无二的绘本!'
|
||||
]
|
||||
|
||||
const currentStepIndex = ref(0)
|
||||
let stepInterval: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
stepInterval = window.setInterval(() => {
|
||||
if (currentStepIndex.value < steps.length - 1) {
|
||||
currentStepIndex.value++
|
||||
}
|
||||
}, 2500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (stepInterval) clearInterval(stepInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[#1C2035] overflow-hidden">
|
||||
<!-- 背景星空 -->
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div v-for="i in 20" :key="i"
|
||||
class="absolute rounded-full bg-white animate-twinkle"
|
||||
:style="{
|
||||
top: `${Math.random() * 100}%`,
|
||||
left: `${Math.random() * 100}%`,
|
||||
width: `${Math.random() * 3 + 1}px`,
|
||||
height: `${Math.random() * 3 + 1}px`,
|
||||
animationDelay: `${Math.random() * 3}s`,
|
||||
opacity: Math.random() * 0.7 + 0.3
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 核心动画:梦境织机 -->
|
||||
<div class="relative w-64 h-64 mb-12 flex items-center justify-center">
|
||||
<!-- 外圈光晕 -->
|
||||
<div class="absolute inset-0 border-4 border-amber-500/20 rounded-full animate-spin-slow"></div>
|
||||
<div class="absolute inset-2 border-2 border-amber-400/30 rounded-full animate-spin-reverse-slower"></div>
|
||||
|
||||
<!-- 核心光球 -->
|
||||
<div class="relative z-10 w-32 h-32 bg-gradient-to-br from-amber-300 to-orange-500 rounded-full shadow-[0_0_60px_rgba(245,158,11,0.5)] animate-pulse-glow flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-white animate-bounce-gentle" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 飞舞的粒子 -->
|
||||
<div class="absolute inset-0 animate-spin-slow">
|
||||
<div class="absolute top-0 left-1/2 w-3 h-3 bg-amber-200 rounded-full shadow-lg blur-[1px]"></div>
|
||||
<div class="absolute bottom-10 right-10 w-2 h-2 bg-purple-300 rounded-full shadow-lg blur-[1px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字提示 -->
|
||||
<div class="z-10 text-center space-y-4">
|
||||
<h3 class="text-3xl font-bold bg-gradient-to-r from-amber-200 to-orange-100 bg-clip-text text-transparent animate-gradient-x">
|
||||
梦境编织中...
|
||||
</h3>
|
||||
<Transition mode="out-in" name="fade-slide">
|
||||
<p :key="currentStepIndex" class="text-stone-300 text-lg font-medium tracking-wide h-8">
|
||||
{{ steps[currentStepIndex] }}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin-reverse-slower {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 0 40px rgba(245,158,11,0.4); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 0 70px rgba(245,158,11,0.7); }
|
||||
}
|
||||
|
||||
@keyframes bounce-gentle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 0.8; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 12s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse-slower {
|
||||
animation: spin-reverse-slower 20s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-gentle {
|
||||
animation: bounce-gentle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-twinkle {
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -84,4 +84,4 @@ function handleClick(event: MouseEvent) {
|
||||
<component v-else-if="props.icon" :is="props.icon" class="h-5 w-5" aria-hidden="true" />
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -40,4 +40,4 @@ const baseClasses = computed(() => [
|
||||
<div :class="[baseClasses, attrs.class]" v-bind="attrs">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -68,4 +68,4 @@ const passthroughAttrs = computed(() => {
|
||||
{{ props.error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -64,4 +64,4 @@ function handleChange(event: Event) {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -59,4 +59,4 @@ const passthroughAttrs = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -57,4 +57,4 @@ const headerClasses = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -42,4 +42,4 @@ function handleAction() {
|
||||
{{ props.actionText }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -33,4 +33,4 @@ const sizeClasses = computed(() => {
|
||||
{{ props.text }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,136 +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>
|
||||
<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>
|
||||
|
||||
@@ -5,4 +5,4 @@ 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'
|
||||
export { default as ConfirmModal } from './ConfirmModal.vue'
|
||||
|
||||
@@ -44,7 +44,7 @@ const router = createRouter({
|
||||
component: () => import('./views/StoryDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/storybook/view',
|
||||
path: '/storybook/view/:id?',
|
||||
name: 'storybook-viewer',
|
||||
component: () => import('./views/StorybookViewer.vue'),
|
||||
},
|
||||
|
||||
@@ -1,49 +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,
|
||||
}
|
||||
})
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,221 +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>
|
||||
<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>
|
||||
|
||||
@@ -171,4 +171,4 @@ onMounted(fetchProfiles)
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -205,4 +205,4 @@ onMounted(fetchUniverse)
|
||||
</BaseCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
14
frontend/src/vite-env.d.ts
vendored
14
frontend/src/vite-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
|
||||
@@ -1,24 +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" }]
|
||||
}
|
||||
{
|
||||
"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" }]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user