Compare commits

1 Commits
Author SHA1 Message Date
haoqimao 6f8134b11a Initial commit 2026-05-19 09:16:37 +00:00
317 changed files with 1 additions and 57982 deletions
-32
View File
@@ -1,32 +0,0 @@
# AI Provider: mock | deepseek | openai | anthropic
AI_PROVIDER=mock
# 日后接真 LLM 时填入对应 Key
# DEEPSEEK_API_KEY=
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# Supabase backend
# SUPABASE_URL=https://xxxx.supabase.co
# SUPABASE_SERVICE_ROLE_KEY=
# NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
# NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Local server storage for uploaded user files.
# Defaults to G:\xinyu on Windows if omitted.
# SERVER_STORAGE_DRIVER=auto # auto | supabase | local
# SERVER_STORAGE_ROOT=G:\xinyu
# Admin backend
# Development default password is "matchu-admin" when ADMIN_PASSWORD is empty.
# ADMIN_PASSWORD=
# ADMIN_SESSION_SECRET=
# 虎皮椒支付 (xunhupay.com)
# XUNHUPAY_APPID=
# XUNHUPAY_KEY=
# XUNHUPAY_NOTIFY_URL=https://your-domain.com/api/payment/webhook
# 限流 / 日志可调
# LOG_LEVEL=info # debug | info | warn | error
# HEALTH_CACHE_MS=10000 # /api/health 探针结果缓存毫秒数
-30
View File
@@ -1,30 +0,0 @@
# MatchU VPS 生产环境变量模板
# Gitea Actions:在仓库 Settings → Actions → Secrets 添加 PROD_ENV_FILE,
# 值为下面整段内容(填好真实值)的多行文本,推送 dev 分支时会写入 .env 供 docker compose 使用。
AI_PROVIDER=mock
# Supabase 后端服务
SUPABASE_URL=https://你的项目.supabase.co
SUPABASE_SERVICE_ROLE_KEY=你的_service_role_key
# Supabase 前台注册登录
NEXT_PUBLIC_SUPABASE_URL=https://你的项目.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=你的_anon_public_key
# 后台登录
ADMIN_PASSWORD=请改成你的后台密码
ADMIN_SESSION_SECRET=请改成一串至少32位的随机字符串
# 上传存储
# 推荐 VPS 也使用 Supabase Storage;如果配置了 Supabase,auto 会自动用 Supabase bucket
SERVER_STORAGE_DRIVER=auto
# 如果你想让 VPS 本地磁盘保存图片,改成:
# SERVER_STORAGE_DRIVER=local
# SERVER_STORAGE_ROOT=/root/matchu-storage
# 虎皮椒支付 (xunhupay.com)
# XUNHUPAY_APPID=
# XUNHUPAY_KEY=
# XUNHUPAY_NOTIFY_URL=https://your-domain.com/api/payment/webhook
-130
View File
@@ -1,130 +0,0 @@
name: Deploy
on:
push:
branches:
- dev
jobs:
deploy:
name: Build and deploy
runs-on: label-ac-1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare environment
env:
PROD_ENV_FILE: ${{ secrets.PROD_ENV_FILE }}
run: |
set -euo pipefail
install_pkg() {
if command -v dnf >/dev/null 2>&1; then
dnf install -y "$@"
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y "$@"
elif command -v yum >/dev/null 2>&1; then
yum install -y "$@"
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache "$@"
else
return 1
fi
}
if [ -n "${PROD_ENV_FILE:-}" ]; then
umask 077
printf '%s\n' "$PROD_ENV_FILE" > .env
fi
test -f .env || {
echo "Missing .env. Add a Gitea secret named PROD_ENV_FILE with the full .env content (see .env.vps.example)."
exit 1
}
if ! command -v docker >/dev/null 2>&1; then
echo "Docker not found. On the host run once: bash deploy/scripts/bootstrap-server.sh"
exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
echo "==> 安装 docker compose v2 plugin..."
if ! install_pkg docker-compose-plugin; then
echo "==> 包源没有 docker-compose-plugin,回退到下载官方二进制..."
case "$(uname -m)" in
x86_64) COMPOSE_ARCH=x86_64 ;;
aarch64|arm64) COMPOSE_ARCH=aarch64 ;;
*) echo "不支持的架构: $(uname -m)"; exit 1 ;;
esac
PLUGIN_DIR=/usr/local/lib/docker/cli-plugins
mkdir -p "$PLUGIN_DIR"
curl -fsSL -o "$PLUGIN_DIR/docker-compose" \
"https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$COMPOSE_ARCH"
chmod +x "$PLUGIN_DIR/docker-compose"
fi
docker compose version
fi
- name: Show Docker info
run: |
docker --version
docker compose version
- name: Build images
run: docker compose build matchu
- name: Start services
run: docker compose up -d --build --remove-orphans
- name: Health check
run: |
set -euo pipefail
for i in $(seq 1 15); do
if docker compose ps matchu --format '{{.State}}' 2>/dev/null | grep -q running; then
break
fi
echo "Waiting for matchu container to start... ($i/15)"
sleep 2
done
docker compose exec -T matchu node - <<'NODE'
const http = require('http');
function probe() {
return new Promise((resolve, reject) => {
const req = http.get('http://127.0.0.1:3000/api/status', { timeout: 3000 }, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
try {
const data = JSON.parse(body);
if (data.ok === true) resolve(data);
else reject(new Error(`status ok=${data.ok}`));
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
});
}
(async () => {
let last;
for (let i = 0; i < 30; i++) {
try {
await probe();
console.log('matchu health ok');
return;
} catch (e) {
last = e;
await new Promise((r) => setTimeout(r, 2000));
}
}
throw last;
})().catch((e) => {
console.error('matchu health check failed:', e);
process.exit(1);
});
NODE
docker compose ps
-51
View File
@@ -1,51 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
*.log
# editors
.vscode/
.idea/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
!.env.vps.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Added by init
reference/*.docx
@@ -1,153 +0,0 @@
<h2>Section 1 · 架构设计</h2>
<p class="subtitle">新增文件结构与数据流向</p>
<style>
.arch-diagram {
font-family: monospace;
background: #0f172a;
color: #e2e8f0;
border-radius: 14px;
padding: 20px 24px;
margin: 16px 0;
font-size: 13px;
line-height: 1.8;
}
.arch-diagram .new { color: #34d399; }
.arch-diagram .existing { color: #94a3b8; }
.arch-diagram .comment { color: #64748b; }
.arch-diagram .changed { color: #fbbf24; }
.flow-container {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 20px 0;
flex-wrap: wrap;
}
.flow-box {
border-radius: 12px;
padding: 12px 14px;
font-size: 12px;
line-height: 1.5;
flex: 1;
min-width: 140px;
}
.flow-box.page { background: #fef3c7; border: 2px solid #f59e0b; }
.flow-box.sheet { background: #ede9fe; border: 2px solid #8b5cf6; }
.flow-box.data { background: #dcfce7; border: 2px solid #22c55e; }
.flow-box.store { background: #dbeafe; border: 2px solid #3b82f6; }
.flow-box h4 { font-size: 11px; font-weight: 700; margin: 0 0 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.flow-arrow {
display: flex; align-items: center;
font-size: 18px; color: #9ca3af;
padding-top: 20px;
}
.types-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin: 16px 0;
}
.type-card {
border-radius: 12px;
padding: 12px;
font-size: 12px;
}
.type-card h4 { font-size: 12px; font-weight: 700; margin: 0 0 6px; }
.type-card ul { margin: 0; padding-left: 14px; color: #4b5563; line-height: 1.8; }
.type-matching { background: #fce7f3; border: 1px solid #fbcfe8; }
.type-quiz { background: #ede9fe; border: 1px solid #ddd6fe; }
.type-gen { background: #fef3c7; border: 1px solid #fde68a; }
.type-fortune { background: #dcfce7; border: 1px solid #bbf7d0; }
</style>
<div class="section">
<h3>新增文件(绿色)</h3>
<div class="arch-diagram">
<span class="existing">app/</span>
<span class="existing"> page.tsx</span> <span class="comment">← 只加 openTool state + 点击处理,约 +20 行</span>
<span class="existing">components/tools/</span>
<span class="new"> ToolSheet.tsx</span> <span class="comment">← 通用底部抽屉壳 + 4 种交互渲染</span>
<span class="new"> ToolResult.tsx</span> <span class="comment">← 多维度结果卡片(可复用)</span>
<span class="new"> ToolInput.tsx</span> <span class="comment">← 输入阶段的表单组件(可复用)</span>
<span class="existing">lib/</span>
<span class="new"> tool-data.ts</span> <span class="comment">← 12 个工具的配置 + 静态数据</span>
<span class="existing"> stores/user-store.ts</span> <span class="comment">← 已有,读取 mbti/zodiac/birthday</span>
</div>
</div>
<div class="section">
<h3>数据流向</h3>
<div class="flow-container">
<div class="flow-box page">
<h4>page.tsx</h4>
ToolTile 被点击<br>
→ 设置 openToolId<br>
→ 渲染 ToolSheet
</div>
<div class="flow-arrow">→</div>
<div class="flow-box sheet">
<h4>ToolSheet.tsx</h4>
读取 tool-data[id]<br>
判断工具类型<br>
渲染输入 → 结果
</div>
<div class="flow-arrow">→</div>
<div class="flow-box data">
<h4>tool-data.ts</h4>
工具配置对象<br>
静态配对表<br>
模板文本数组
</div>
<div class="flow-arrow">↔</div>
<div class="flow-box store">
<h4>useUserStore</h4>
用户星座<br>
MBTI 类型<br>
生日信息
</div>
</div>
</div>
<div class="section">
<h3>12 个工具按交互类型分组</h3>
<div class="types-grid">
<div class="type-card type-matching">
<h4>🔀 配对型(2个)</h4>
<ul>
<li>⭐ 星座配对</li>
<li>🧠 MBTI 契合</li>
</ul>
<div style="margin-top:6px;font-size:11px;color:#9d174d">选择器 A × 选择器 B → 配对表查询 → 多维分数</div>
</div>
<div class="type-card type-quiz">
<h4>📋 测试型(3个)</h4>
<ul>
<li>💖 恋爱人格</li>
<li>🌡️ 暧昧温度</li>
<li>🎂 缘分生日</li>
</ul>
<div style="margin-top:6px;font-size:11px;color:#5b21b6">4-5 道选择题 → 累计分数 → 类型/温度</div>
</div>
<div class="type-card type-gen">
<h4>✍️ 生成型(4个)</h4>
<ul>
<li>💌 AI 情书</li>
<li>💘 告白脚本</li>
<li>🎬 心动剧本</li>
<li>🌙 梦境解码</li>
</ul>
<div style="margin-top:6px;font-size:11px;color:#92400e">填写关键词 → 模板填空 → 文字结果</div>
</div>
<div class="type-card type-fortune">
<h4>🔮 运势型(3个)</h4>
<ul>
<li>🔮 塔罗占卜</li>
<li>🌸 桃花运</li>
<li>💑 虚拟恋人</li>
</ul>
<div style="margin-top:6px;font-size:11px;color:#166534">动画交互(翻牌/抽签)→ 随机解读</div>
</div>
</div>
</div>
@@ -1,363 +0,0 @@
<h2>Section 2 · 各类型交互流程</h2>
<p class="subtitle">底部抽屉内 3 个阶段:输入 → 计算动画 → 多维结果</p>
<style>
.type-section { margin-bottom: 32px; }
.type-header {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px;
border-radius: 12px;
margin-bottom: 14px;
font-weight: 700; font-size: 14px;
}
.type-matching-bg { background: #fce7f3; color: #9d174d; }
.type-quiz-bg { background: #ede9fe; color: #5b21b6; }
.type-gen-bg { background: #fef3c7; color: #92400e; }
.type-fortune-bg { background: #dcfce7; color: #166534; }
.phases {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.phase {
border-radius: 12px;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.phase-label {
text-align: center;
font-size: 10px;
font-weight: 700;
padding: 5px;
background: #f9fafb;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #e5e7eb;
}
.phase-body {
padding: 12px 10px;
min-height: 140px;
display: flex;
flex-direction: column;
gap: 7px;
}
/* Input elements */
.mini-label { font-size: 9px; color: #9ca3af; margin-bottom: 2px; }
.mini-select {
width: 100%;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 7px;
padding: 5px 8px;
font-size: 10px;
color: #374151;
}
.mini-select.user-filled {
background: #fdf2f8;
border-color: #f9a8d4;
color: #be185d;
}
.auto-tag {
font-size: 8px;
background: #fce7f3;
color: #be185d;
padding: 1px 5px;
border-radius: 4px;
display: inline-block;
margin-left: 4px;
}
.mini-btn {
width: 100%;
padding: 6px;
border: none;
border-radius: 7px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
margin-top: auto;
}
.btn-pink { background: linear-gradient(135deg, #ec4899, #f43f5e); color: white; }
.btn-purple { background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white; }
.btn-amber { background: linear-gradient(135deg, #f59e0b, #ef4444); color: white; }
.btn-green { background: linear-gradient(135deg, #22c55e, #10b981); color: white; }
/* Loading */
.loading-center {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 6px; flex: 1;
}
.spinner {
width: 32px; height: 32px;
border: 3px solid #f3f4f6;
border-top-color: #ec4899;
border-radius: 50%;
}
.loading-text { font-size: 10px; color: #9ca3af; text-align: center; }
/* Result */
.result-dim {
font-size: 9px; display: flex; align-items: center; gap: 4px;
}
.dim-bar-wrap { flex: 1; height: 5px; background: #f3f4f6; border-radius: 3px; }
.dim-bar { height: 100%; border-radius: 3px; }
.dim-score { font-size: 9px; font-weight: 700; width: 22px; text-align: right; color: #374151; }
.result-total {
text-align: center; font-size: 11px; font-weight: 700;
padding: 5px; background: #fdf2f8; border-radius: 7px;
color: #be185d; margin-top: 2px;
}
.result-quote { font-size: 9px; color: #6b7280; text-align: center; line-height: 1.4; }
/* Quiz option */
.quiz-q { font-size: 10px; font-weight: 600; color: #1f2937; margin-bottom: 5px; }
.quiz-step { font-size: 9px; color: #9ca3af; margin-bottom: 4px; }
.quiz-opt {
font-size: 9px; padding: 4px 8px;
border: 1px solid #e5e7eb; border-radius: 6px;
background: white; cursor: pointer; display: block; width: 100%;
text-align: left; color: #374151;
}
.quiz-opt.active { border-color: #ec4899; background: #fdf2f8; color: #be185d; }
.quiz-progress {
height: 3px; background: #f3f4f6; border-radius: 2px; margin-bottom: 4px;
}
.quiz-fill { height: 100%; background: linear-gradient(90deg, #ec4899, #f43f5e); border-radius: 2px; }
/* Gen type */
.mini-textarea {
width: 100%; font-size: 9px;
border: 1px solid #e5e7eb; border-radius: 7px;
padding: 5px 7px; resize: none; background: #f9fafb;
color: #374151; box-sizing: border-box;
}
.mini-chips { display: flex; flex-wrap: wrap; gap: 3px; }
.mini-chip {
font-size: 9px; padding: 2px 7px;
border-radius: 20px; border: 1px solid #e5e7eb;
cursor: pointer; color: #6b7280;
}
.mini-chip.active { border-color: #ec4899; background: #fdf2f8; color: #be185d; }
/* Fortune type */
.tarot-cards {
display: flex; gap: 5px; justify-content: center;
padding: 8px 0;
}
.tarot-card {
width: 32px; height: 50px;
border-radius: 5px;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
}
.tarot-back { background: linear-gradient(135deg, #7c3aed, #4f46e5); }
.tarot-revealed {
background: linear-gradient(135deg, #fef3c7, #fde68a);
border: 1px solid #f59e0b;
}
.tarot-hint { font-size: 9px; color: #9ca3af; text-align: center; }
</style>
<!-- TYPE 1: 配对型 -->
<div class="type-section">
<div class="type-header type-matching-bg">🔀 配对型 · 星座配对 / MBTI 契合</div>
<div class="phases">
<div class="phase">
<div class="phase-label">① 输入</div>
<div class="phase-body">
<div>
<div class="mini-label">我的星座 <span class="auto-tag">自动填入</span></div>
<div class="mini-select user-filled">♎️ 天秤座</div>
</div>
<div>
<div class="mini-label">TA 的星座</div>
<select class="mini-select"><option>请选择...</option></select>
</div>
<button class="mini-btn btn-pink">✨ 开始配对</button>
</div>
</div>
<div class="phase">
<div class="phase-label">② 计算中</div>
<div class="phase-body">
<div class="loading-center">
<div class="spinner"></div>
<div class="loading-text">正在解析<br>星座能量场...</div>
</div>
</div>
</div>
<div class="phase">
<div class="phase-label">③ 结果</div>
<div class="phase-body">
<div class="result-dim">
<span>💕</span>
<div class="dim-bar-wrap"><div class="dim-bar" style="width:88%;background:linear-gradient(90deg,#ec4899,#f43f5e)"></div></div>
<span class="dim-score">88</span>
</div>
<div class="result-dim">
<span>💬</span>
<div class="dim-bar-wrap"><div class="dim-bar" style="width:72%;background:linear-gradient(90deg,#8b5cf6,#6366f1)"></div></div>
<span class="dim-score">72</span>
</div>
<div class="result-dim">
<span>🌱</span>
<div class="dim-bar-wrap"><div class="dim-bar" style="width:65%;background:linear-gradient(90deg,#f59e0b,#ef4444)"></div></div>
<span class="dim-score">65</span>
</div>
<div class="result-total">综合契合度 82% 💖</div>
<div class="result-quote">感性与理性的完美交融,在浪漫中心心相印</div>
</div>
</div>
</div>
</div>
<!-- TYPE 2: 测试型 -->
<div class="type-section">
<div class="type-header type-quiz-bg">📋 测试型 · 恋爱人格 / 暧昧温度 / 缘分生日</div>
<div class="phases">
<div class="phase">
<div class="phase-label">① 答题(4步)</div>
<div class="phase-body">
<div class="quiz-progress"><div class="quiz-fill" style="width:50%"></div></div>
<div class="quiz-step">第 2 题 / 共 4 题</div>
<div class="quiz-q">TA 突然消失三天,你会?</div>
<button class="quiz-opt active">😤 发消息质问</button>
<button class="quiz-opt">😌 等 TA 主动联系</button>
<button class="quiz-opt">🥺 假装没事刷朋友圈</button>
</div>
</div>
<div class="phase">
<div class="phase-label">② 分析中</div>
<div class="phase-body">
<div class="loading-center">
<div style="font-size:28px">💖</div>
<div class="loading-text">正在解析你的<br>恋爱人格...</div>
</div>
</div>
</div>
<div class="phase">
<div class="phase-label">③ 结果</div>
<div class="phase-body">
<div class="result-dim">
<span>🔥</span>
<div class="dim-bar-wrap"><div class="dim-bar" style="width:90%;background:linear-gradient(90deg,#ef4444,#f97316)"></div></div>
<span class="dim-score">热情</span>
</div>
<div class="result-dim">
<span>🛡️</span>
<div class="dim-bar-wrap"><div class="dim-bar" style="width:60%;background:linear-gradient(90deg,#3b82f6,#6366f1)"></div></div>
<span class="dim-score">安全</span>
</div>
<div class="result-dim">
<span>🦋</span>
<div class="dim-bar-wrap"><div class="dim-bar" style="width:75%;background:linear-gradient(90deg,#ec4899,#a855f7)"></div></div>
<span class="dim-score">浪漫</span>
</div>
<div class="result-total">🔥 激情主导型</div>
<div class="result-quote">热烈而直接,爱得燃烧</div>
</div>
</div>
</div>
</div>
<!-- TYPE 3: 生成型 -->
<div class="type-section">
<div class="type-header type-gen-bg">✍️ 生成型 · AI情书 / 告白脚本 / 心动剧本 / 梦境解码</div>
<div class="phases">
<div class="phase">
<div class="phase-label">① 填写</div>
<div class="phase-body">
<div>
<div class="mini-label">TA 的名字</div>
<div class="mini-select">输入 TA 的名字</div>
</div>
<div>
<div class="mini-label">风格</div>
<div class="mini-chips">
<span class="mini-chip active">💌 浪漫</span>
<span class="mini-chip">😂 搞笑</span>
<span class="mini-chip">🌙 文艺</span>
<span class="mini-chip">🔥 热烈</span>
</div>
</div>
<div>
<div class="mini-label">你们的一个小故事</div>
<textarea class="mini-textarea" rows="2" placeholder="比如:第一次见面在图书馆..."></textarea>
</div>
<button class="mini-btn btn-amber">💌 生成情书</button>
</div>
</div>
<div class="phase">
<div class="phase-label">② 生成中</div>
<div class="phase-body">
<div class="loading-center">
<div style="font-size:28px">✍️</div>
<div class="loading-text">正在为你斟酌<br>每一个字...</div>
</div>
</div>
</div>
<div class="phase">
<div class="phase-label">③ 结果</div>
<div class="phase-body">
<div style="font-size:9px;color:#374151;line-height:1.6;background:#fef3c7;border-radius:8px;padding:8px;">
亲爱的小雨,<br>
你知道吗,那天图书馆的午后阳光,把你映成了我最美的书签...
</div>
<button class="mini-btn btn-amber" style="margin-top:auto">📋 复制 &nbsp; 📤 分享</button>
</div>
</div>
</div>
</div>
<!-- TYPE 4: 运势型 -->
<div class="type-section">
<div class="type-header type-fortune-bg">🔮 运势型 · 塔罗占卜 / 桃花运 / 虚拟恋人</div>
<div class="phases">
<div class="phase">
<div class="phase-label">① 集中心神</div>
<div class="phase-body">
<div class="tarot-cards">
<div class="tarot-card tarot-back">🌟</div>
<div class="tarot-card tarot-back">🌟</div>
<div class="tarot-card tarot-back">🌟</div>
</div>
<div class="tarot-hint">想着你的感情问题<br>点击选一张牌</div>
<button class="mini-btn btn-green" style="margin-top:8px">🔮 抽取塔罗牌</button>
</div>
</div>
<div class="phase">
<div class="phase-label">② 翻牌动画</div>
<div class="phase-body">
<div class="tarot-cards">
<div class="tarot-card tarot-back">🌟</div>
<div class="tarot-card tarot-revealed">🌙</div>
<div class="tarot-card tarot-back">🌟</div>
</div>
<div class="tarot-hint" style="color:#f59e0b;font-weight:700">月亮牌</div>
<div class="loading-center" style="gap:3px">
<div class="loading-text">正在解读牌义...</div>
</div>
</div>
</div>
<div class="phase">
<div class="phase-label">③ 解读</div>
<div class="phase-body">
<div style="text-align:center;font-size:18px;margin-bottom:4px">🌙</div>
<div class="result-total" style="background:#dcfce7;color:#166534">月亮 · 直觉与迷雾</div>
<div class="result-dim">
<span>💕</span>
<div class="dim-bar-wrap"><div class="dim-bar" style="width:70%;background:linear-gradient(90deg,#22c55e,#10b981)"></div></div>
<span class="dim-score">感情</span>
</div>
<div class="result-dim">
<span>⚡</span>
<div class="dim-bar-wrap"><div class="dim-bar" style="width:55%;background:linear-gradient(90deg,#f59e0b,#ef4444)"></div></div>
<span class="dim-score">行动</span>
</div>
<div class="result-quote">跟随内心直觉,感情将水到渠成</div>
</div>
</div>
</div>
</div>
@@ -1,224 +0,0 @@
<h2>点击工具后,用哪种方式打开?</h2>
<p class="subtitle">选择最符合你期望的交互体验</p>
<style>
.phone-frame {
width: 200px;
height: 360px;
border: 3px solid #1f2937;
border-radius: 32px;
background: #f9fafb;
position: relative;
overflow: hidden;
margin: 0 auto;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.phone-notch {
width: 60px;
height: 14px;
background: #1f2937;
border-radius: 0 0 10px 10px;
margin: 0 auto;
}
.phone-screen {
padding: 8px;
height: calc(100% - 14px);
position: relative;
overflow: hidden;
}
.mini-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
margin-top: 6px;
}
.mini-tile {
height: 48px;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
}
.mini-tile.t1 { background: linear-gradient(135deg, #ff6b9d, #ff9a3c); }
.mini-tile.t2 { background: linear-gradient(135deg, #a855f7, #ec4899); }
.mini-tile.t3 { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
.mini-tile.t4 { background: linear-gradient(135deg, #14b8a6, #0ea5e9); }
.mini-tile.t5 { background: linear-gradient(135deg, #f59e0b, #ef4444); }
.mini-tile.t6 { background: linear-gradient(135deg, #ec4899, #f43f5e); }
.mini-tile-name { font-size: 8px; color: white; margin-top: 2px; }
/* Bottom Sheet */
.bottom-sheet {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 200px;
background: white;
border-radius: 18px 18px 0 0;
padding: 12px 12px 8px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
}
.sheet-handle {
width: 32px;
height: 4px;
background: #e5e7eb;
border-radius: 2px;
margin: 0 auto 10px;
}
.sheet-title { font-size: 12px; font-weight: 700; color: #1f2937; margin-bottom: 6px; }
.sheet-content { font-size: 9px; color: #6b7280; line-height: 1.4; }
.sheet-btn {
background: linear-gradient(135deg, #ff6b9d, #ff9a3c);
color: white;
border: none;
border-radius: 8px;
padding: 5px 10px;
font-size: 9px;
width: 100%;
margin-top: 8px;
cursor: pointer;
}
/* Full Screen Modal */
.fullscreen-overlay {
position: absolute;
inset: 0;
background: white;
border-radius: 29px;
padding: 16px 12px;
}
.back-btn {
font-size: 10px;
color: #6b7280;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 3px;
}
.full-tool-header {
background: linear-gradient(135deg, #ff6b9d, #ff9a3c);
border-radius: 14px;
padding: 10px;
text-align: center;
color: white;
margin-bottom: 10px;
}
.full-tool-emoji { font-size: 24px; }
.full-tool-name { font-size: 11px; font-weight: 700; margin-top: 2px; }
.full-tool-body { font-size: 9px; color: #6b7280; text-align: center; margin-bottom: 8px; }
.full-input {
width: 100%;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 5px 8px;
font-size: 9px;
box-sizing: border-box;
margin-bottom: 6px;
}
.full-btn {
background: linear-gradient(135deg, #ff6b9d, #ff9a3c);
color: white;
border: none;
border-radius: 8px;
padding: 6px;
font-size: 9px;
width: 100%;
cursor: pointer;
}
/* Dim overlay */
.dim {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.4);
border-radius: 29px;
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 24px;
}
.card {
border: 2px solid #e5e7eb;
border-radius: 20px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.card:hover { border-color: #f472b6; transform: translateY(-3px); box-shadow: 0 8px 24px rgba(236,72,153,0.15); }
.card.selected { border-color: #ec4899; box-shadow: 0 8px 24px rgba(236,72,153,0.25); }
.card-image { padding: 20px 16px; background: #f9fafb; border-bottom: 1px solid #f3f4f6; }
.card-body { padding: 14px; }
.card-body h3 { font-size: 14px; font-weight: 700; color: #1f2937; margin: 0 0 6px; }
.card-body p { font-size: 12px; color: #6b7280; margin: 0; line-height: 1.5; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 10px; margin-top: 6px; }
.tag-green { background: #dcfce7; color: #16a34a; }
.tag-blue { background: #dbeafe; color: #1d4ed8; }
.tag-orange { background: #ffedd5; color: #ea580c; }
</style>
<div class="cards">
<!-- Option A: Bottom Sheet -->
<div class="card" data-choice="bottom-sheet" onclick="toggleSelect(this)">
<div class="card-image">
<div class="phone-frame">
<div class="phone-notch"></div>
<div class="phone-screen">
<div class="mini-grid">
<div class="mini-tile t1">🔮<span class="mini-tile-name">塔罗</span></div>
<div class="mini-tile t2">⭐<span class="mini-tile-name">星座</span></div>
<div class="mini-tile t3">🧠<span class="mini-tile-name">MBTI</span></div>
<div class="mini-tile t4">💌<span class="mini-tile-name">情书</span></div>
<div class="mini-tile t5">🌡️<span class="mini-tile-name">温度</span></div>
<div class="mini-tile t6">💘<span class="mini-tile-name">告白</span></div>
</div>
<div class="dim"></div>
<div class="bottom-sheet">
<div class="sheet-handle"></div>
<div class="sheet-title">🔮 塔罗占卜</div>
<div class="sheet-content">集中心神,想着你的感情问题,点击翻开属于你的塔罗牌...</div>
<button class="sheet-btn">✨ 开始占卜</button>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A · 底部弹出抽屉</h3>
<p>工具内容从底部滑出,背景半透明遮罩。随时下滑关闭,不离开当前页面。</p>
<span class="tag tag-green">✓ 操作流畅</span>
<span class="tag tag-green">✓ 无跳转感</span>
</div>
</div>
<!-- Option B: Full Screen -->
<div class="card" data-choice="fullscreen" onclick="toggleSelect(this)">
<div class="card-image">
<div class="phone-frame">
<div class="phone-notch"></div>
<div class="phone-screen">
<div class="fullscreen-overlay">
<div class="back-btn">← 返回盲盒</div>
<div class="full-tool-header">
<div class="full-tool-emoji">🔮</div>
<div class="full-tool-name">塔罗占卜</div>
</div>
<div class="full-tool-body">集中心神,想着你的感情问题...</div>
<input class="full-input" placeholder="输入你的问题...">
<button class="full-btn">✨ 翻牌占卜</button>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B · 全屏沉浸页面</h3>
<p>点击工具后跳转到独立全屏页面,有更多空间展示结果和动画效果。</p>
<span class="tag tag-blue">✓ 空间充足</span>
<span class="tag tag-orange">需返回操作</span>
</div>
</div>
</div>
@@ -1,180 +0,0 @@
<h2>工具结果,用哪种样式展示?</h2>
<p class="subtitle">以「星座配对」为例,对比三种结果呈现方式</p>
<style>
.cards { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; margin-top: 20px; }
.card {
border: 2px solid #e5e7eb;
border-radius: 18px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.card:hover { border-color: #f472b6; transform: translateY(-3px); box-shadow: 0 6px 20px rgba(236,72,153,0.15); }
.card.selected { border-color: #ec4899; box-shadow: 0 6px 20px rgba(236,72,153,0.25); }
.card-preview { padding: 16px 12px; background: #f9fafb; border-bottom: 1px solid #f3f4f6; min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.card-body { padding: 12px; }
.card-body h3 { font-size: 13px; font-weight: 700; color: #1f2937; margin: 0 0 5px; }
.card-body p { font-size: 11px; color: #6b7280; margin: 0; line-height: 1.5; }
.tag { display: inline-block; padding: 2px 7px; border-radius: 20px; font-size: 10px; margin-top: 5px; margin-right: 3px; }
.tag-green { background: #dcfce7; color: #16a34a; }
.tag-blue { background: #dbeafe; color: #1d4ed8; }
.tag-purple { background: #f3e8ff; color: #7c3aed; }
/* Style A: Simple text */
.result-simple {
text-align: center;
width: 100%;
}
.score-ring {
width: 72px; height: 72px;
border-radius: 50%;
background: conic-gradient(#ec4899 0% 82%, #fce7f3 82% 100%);
display: flex; align-items: center; justify-content: center;
margin: 0 auto 10px;
position: relative;
}
.score-inner {
width: 56px; height: 56px;
background: white;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 15px; font-weight: 800; color: #be185d;
}
.result-title { font-size: 13px; font-weight: 700; color: #1f2937; margin-bottom: 4px; }
.result-desc { font-size: 10px; color: #6b7280; line-height: 1.4; }
.copy-btn {
display: block; width: 100%;
margin-top: 10px; padding: 6px;
background: #fce7f3; color: #be185d;
border: none; border-radius: 8px;
font-size: 10px; cursor: pointer;
}
/* Style B: Share card */
.share-card {
width: 100%;
background: linear-gradient(135deg, #ff6b9d 0%, #ff9a3c 100%);
border-radius: 14px;
padding: 14px 12px;
color: white;
text-align: center;
position: relative;
overflow: hidden;
}
.share-card::before {
content: '✦';
position: absolute; top: 6px; right: 10px;
font-size: 18px; opacity: 0.3;
}
.share-pair { font-size: 18px; margin-bottom: 6px; }
.share-score { font-size: 28px; font-weight: 900; }
.share-label { font-size: 9px; opacity: 0.8; margin-bottom: 6px; }
.share-text { font-size: 10px; line-height: 1.4; opacity: 0.9; }
.share-btn {
display: block; width: 100%;
margin-top: 10px; padding: 5px;
background: rgba(255,255,255,0.25);
border: 1px solid rgba(255,255,255,0.5);
border-radius: 8px; color: white;
font-size: 9px; cursor: pointer;
}
/* Style C: Animated steps */
.steps-result { width: 100%; }
.step-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #f3f4f6;
}
.step-row:last-child { border-bottom: none; }
.step-icon { font-size: 16px; flex-shrink: 0; }
.step-info { flex: 1; }
.step-label { font-size: 9px; color: #9ca3af; }
.step-value { font-size: 11px; font-weight: 600; color: #1f2937; }
.step-bar { height: 4px; background: #f3f4f6; border-radius: 2px; margin-top: 3px; }
.step-fill { height: 100%; border-radius: 2px; }
.fill-love { width: 88%; background: linear-gradient(90deg, #ec4899, #f43f5e); }
.fill-comm { width: 72%; background: linear-gradient(90deg, #8b5cf6, #6366f1); }
.fill-grow { width: 65%; background: linear-gradient(90deg, #f59e0b, #ef4444); }
.total-row {
text-align: center; padding-top: 8px;
font-size: 12px; font-weight: 700; color: #be185d;
}
</style>
<div class="cards">
<!-- Option A: Simple score -->
<div class="card" data-choice="simple" onclick="toggleSelect(this)">
<div class="card-preview">
<div class="result-simple">
<div class="score-ring"><div class="score-inner">82%</div></div>
<div class="result-title">天秤座 × 双鱼座</div>
<div class="result-desc">你们如诗如画,感性与理性交融,容易在艺术和浪漫中找到共鸣。</div>
<button class="copy-btn">📋 复制结果</button>
</div>
</div>
<div class="card-body">
<h3>A · 分数 + 文字解读</h3>
<p>一个圆环显示契合度,下方一段解读文字,简洁明了。</p>
<span class="tag tag-green">✓ 简洁快速</span>
</div>
</div>
<!-- Option B: Share card -->
<div class="card" data-choice="share-card" onclick="toggleSelect(this)">
<div class="card-preview">
<div class="share-card">
<div class="share-pair">♎️ × ♓️</div>
<div class="share-score">82%</div>
<div class="share-label">星座契合指数</div>
<div class="share-text">如诗如画的组合,感性与理性的完美交融,在浪漫与艺术中心心相印 ✨</div>
<button class="share-btn">📤 分享给 TA</button>
</div>
</div>
<div class="card-body">
<h3>B · 可分享卡片</h3>
<p>渐变背景的精美卡片,可截图分享给对方,顺带给 APP 引流。</p>
<span class="tag tag-purple">✓ 可分享传播</span>
</div>
</div>
<!-- Option C: Detailed breakdown -->
<div class="card" data-choice="detailed" onclick="toggleSelect(this)">
<div class="card-preview">
<div class="steps-result">
<div class="step-row">
<div class="step-icon">💕</div>
<div class="step-info">
<div class="step-label">情感共鸣</div>
<div class="step-value">88分</div>
<div class="step-bar"><div class="step-fill fill-love"></div></div>
</div>
</div>
<div class="step-row">
<div class="step-icon">💬</div>
<div class="step-info">
<div class="step-label">沟通方式</div>
<div class="step-value">72分</div>
<div class="step-bar"><div class="step-fill fill-comm"></div></div>
</div>
</div>
<div class="step-row">
<div class="step-icon">🌱</div>
<div class="step-info">
<div class="step-label">共同成长</div>
<div class="step-value">65分</div>
<div class="step-bar"><div class="step-fill fill-grow"></div></div>
</div>
</div>
<div class="total-row">综合契合度 82% 💖</div>
</div>
</div>
<div class="card-body">
<h3>C · 多维度拆解</h3>
<p>拆分成情感、沟通、成长等维度,每项单独评分,更有深度。</p>
<span class="tag tag-blue">✓ 内容丰富</span>
</div>
</div>
</div>
@@ -1,145 +0,0 @@
<h2>12 个工具,先做哪些?</h2>
<p class="subtitle">心动盲盒目前全部显示「即将开放」,你希望先把哪些工具做成真正可交互的?(可多选)</p>
<style>
.tools-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 24px;
}
.tool-card {
border: 2px solid #e5e7eb;
border-radius: 16px;
padding: 16px 12px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
background: #fafafa;
user-select: none;
}
.tool-card:hover {
border-color: #f472b6;
background: #fdf2f8;
transform: translateY(-2px);
}
.tool-card.selected {
border-color: #ec4899;
background: linear-gradient(135deg, #fdf2f8, #fce7f3);
box-shadow: 0 4px 12px rgba(236,72,153,0.2);
}
.tool-card .emoji {
font-size: 28px;
display: block;
margin-bottom: 6px;
}
.tool-card .name {
font-size: 13px;
font-weight: 600;
color: #374151;
display: block;
margin-bottom: 4px;
}
.tool-card .desc {
font-size: 11px;
color: #9ca3af;
}
.tool-card.selected .name {
color: #be185d;
}
.hint {
margin-top: 20px;
padding: 12px 16px;
background: #f0fdf4;
border-radius: 10px;
font-size: 13px;
color: #16a34a;
text-align: center;
}
</style>
<div class="tools-grid">
<div class="tool-card" data-choice="love-personality" onclick="toggleSelect(this)">
<span class="emoji">💖</span>
<span class="name">恋爱人格</span>
<span class="desc">测出你的恋爱类型</span>
</div>
<div class="tool-card" data-choice="heartbeat-script" onclick="toggleSelect(this)">
<span class="emoji">🎬</span>
<span class="name">心动剧本</span>
<span class="desc">AI 生成约会场景剧本</span>
</div>
<div class="tool-card" data-choice="zodiac" onclick="toggleSelect(this)">
<span class="emoji">⭐</span>
<span class="name">星座配对</span>
<span class="desc">两人星座契合度分析</span>
</div>
<div class="tool-card" data-choice="tarot" onclick="toggleSelect(this)">
<span class="emoji">🔮</span>
<span class="name">塔罗占卜</span>
<span class="desc">翻牌揭示感情走向</span>
</div>
<div class="tool-card" data-choice="mbti" onclick="toggleSelect(this)">
<span class="emoji">🧠</span>
<span class="name">MBTI 契合</span>
<span class="desc">性格类型匹配分析</span>
</div>
<div class="tool-card" data-choice="ambiguous-temp" onclick="toggleSelect(this)">
<span class="emoji">🌡️</span>
<span class="name">暧昧温度</span>
<span class="desc">测量关系热度值</span>
</div>
<div class="tool-card" data-choice="virtual-lover" onclick="toggleSelect(this)">
<span class="emoji">💑</span>
<span class="name">虚拟恋人</span>
<span class="desc">AI 陪伴对话体验</span>
</div>
<div class="tool-card" data-choice="dream-decode" onclick="toggleSelect(this)">
<span class="emoji">🌙</span>
<span class="name">梦境解码</span>
<span class="desc">解析梦中感情信号</span>
</div>
<div class="tool-card" data-choice="destiny-birthday" onclick="toggleSelect(this)">
<span class="emoji">🎂</span>
<span name="name">缘分生日</span>
<span class="desc">生日数字命理分析</span>
</div>
<div class="tool-card" data-choice="love-letter" onclick="toggleSelect(this)">
<span class="emoji">💌</span>
<span class="name">AI 情书</span>
<span class="desc">为你生成专属情书</span>
</div>
<div class="tool-card" data-choice="peach-luck" onclick="toggleSelect(this)">
<span class="emoji">🌸</span>
<span class="name">桃花运</span>
<span class="desc">本月桃花运势预测</span>
</div>
<div class="tool-card" data-choice="confession" onclick="toggleSelect(this)">
<span class="emoji">💘</span>
<span class="name">告白脚本</span>
<span class="desc">AI 定制告白台词</span>
</div>
</div>
<div class="hint">💡 点击选中你想优先做的工具,也可以选「全部」</div>
<div style="margin-top:16px; text-align:center;">
<div class="tool-card" data-choice="all" onclick="toggleSelect(this)" style="display:inline-block; padding: 12px 32px; width: auto;">
<span class="emoji">✨</span>
<span class="name">全部 12 个都要做</span>
</div>
</div>
<script>
// Override toggleSelect for multi-select behavior
(function() {
const origToggle = window.toggleSelect;
window.toggleSelect = function(el) {
el.classList.toggle('selected');
// Report all selected
const selected = [...document.querySelectorAll('.tool-card.selected')]
.map(c => c.dataset.choice);
if (window.reportChoice) window.reportChoice(selected.join(','), selected.map(c => c).join(', '));
};
})();
</script>
@@ -1,3 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -1 +0,0 @@
{"reason":"idle timeout","timestamp":1777566863270}
@@ -1 +0,0 @@
1855761
@@ -1,87 +0,0 @@
<h2>AI 示例形象展示区</h2>
<p class="subtitle">页面顶部向用户展示"AI 能生成什么效果",MVP 阶段用哪种方式最合适?</p>
<div class="cards" style="margin-top:20px">
<!-- Option A: Horizontal avatar row -->
<div class="card" data-choice="a" onclick="toggleSelect(this)" style="cursor:pointer">
<div class="card-image" style="padding:14px;background:linear-gradient(160deg,#1a0a2e,#0d0720);border-radius:12px 12px 0 0">
<div style="font-size:10px;color:rgba(255,255,255,0.4);margin-bottom:10px;text-align:center;letter-spacing:1px;text-transform:uppercase">✨ AI 生成效果示例</div>
<!-- Avatar row: 5 sample avatars with names -->
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap">
<!-- Each avatar: gradient ring + emoji + name label -->
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="width:54px;height:54px;border-radius:50%;background:linear-gradient(135deg,#fda4af,#fb7185);display:flex;align-items:center;justify-content:center;font-size:26px;box-shadow:0 0 0 2px rgba(253,164,175,0.4),0 0 14px rgba(253,164,175,0.25)">🌸</div>
<span style="font-size:8px;color:rgba(255,255,255,0.5)">樱花少女</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="width:54px;height:54px;border-radius:50%;background:linear-gradient(135deg,#93c5fd,#60a5fa);display:flex;align-items:center;justify-content:center;font-size:26px;box-shadow:0 0 0 2px rgba(147,197,253,0.4),0 0 14px rgba(147,197,253,0.25)">🌙</div>
<span style="font-size:8px;color:rgba(255,255,255,0.5)">月夜精灵</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="width:54px;height:54px;border-radius:50%;background:linear-gradient(135deg,#d9f99d,#86efac);display:flex;align-items:center;justify-content:center;font-size:26px;box-shadow:0 0 0 2px rgba(217,249,157,0.4),0 0 14px rgba(217,249,157,0.25)">🍀</div>
<span style="font-size:8px;color:rgba(255,255,255,0.5)">清新精灵</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="width:54px;height:54px;border-radius:50%;background:linear-gradient(135deg,#fde68a,#fbbf24);display:flex;align-items:center;justify-content:center;font-size:26px;box-shadow:0 0 0 2px rgba(253,230,138,0.4),0 0 14px rgba(253,230,138,0.25)">☀️</div>
<span style="font-size:8px;color:rgba(255,255,255,0.5)">阳光女孩</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="width:54px;height:54px;border-radius:50%;background:linear-gradient(135deg,#e9d5ff,#c084fc);display:flex;align-items:center;justify-content:center;font-size:26px;box-shadow:0 0 0 2px rgba(196,132,252,0.4),0 0 14px rgba(196,132,252,0.25)">🔮</div>
<span style="font-size:8px;color:rgba(255,255,255,0.5)">梦幻魔女</span>
</div>
</div>
<div style="text-align:center;margin-top:10px;font-size:9px;color:rgba(255,255,255,0.3)">上传照片后系统将为你生成专属风格</div>
</div>
<div class="card-body">
<h3>A · 风格化示例头像行</h3>
<p>展示 5 个预设风格的卡通头像(渐变 + emoji),每个有名字标签。用户知道会生成"这种风格",视觉简洁不占空间。</p>
</div>
</div>
<!-- Option B: Before/After transformation cards -->
<div class="card" data-choice="b" onclick="toggleSelect(this)" style="cursor:pointer">
<div class="card-image" style="padding:14px;background:linear-gradient(160deg,#1a0a2e,#0d0720);border-radius:12px 12px 0 0">
<div style="font-size:10px;color:rgba(255,255,255,0.4);margin-bottom:10px;text-align:center;letter-spacing:1px;text-transform:uppercase">✨ 用户案例 · 真实效果</div>
<!-- 2 before/after cards side by side -->
<div style="display:flex;gap:10px;justify-content:center">
<!-- Card 1 -->
<div style="background:rgba(255,255,255,0.06);border-radius:12px;padding:8px;width:110px;border:1px solid rgba(255,255,255,0.1)">
<div style="display:flex;gap:6px;align-items:center;justify-content:center">
<div style="width:36px;height:36px;border-radius:50%;background:#4a2060;display:flex;align-items:center;justify-content:center;font-size:16px;border:1px dashed rgba(255,255,255,0.2)">👩</div>
<div style="font-size:12px;color:rgba(255,255,255,0.3)">→</div>
<div style="width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,#f9a8d4,#c084fc);display:flex;align-items:center;justify-content:center;font-size:16px;box-shadow:0 0 10px rgba(249,168,212,0.4)">🌸</div>
</div>
<div style="text-align:center;margin-top:6px">
<div style="font-size:8px;color:rgba(255,255,255,0.5)">Amy · 樱花少女</div>
<div style="display:flex;justify-content:center;gap:3px;margin-top:3px">
<span style="font-size:7px;color:#fbbf24">★★★★★</span>
</div>
</div>
</div>
<!-- Card 2 -->
<div style="background:rgba(255,255,255,0.06);border-radius:12px;padding:8px;width:110px;border:1px solid rgba(255,255,255,0.1)">
<div style="display:flex;gap:6px;align-items:center;justify-content:center">
<div style="width:36px;height:36px;border-radius:50%;background:#1a2f5e;display:flex;align-items:center;justify-content:center;font-size:16px;border:1px dashed rgba(255,255,255,0.2)">🧑</div>
<div style="font-size:12px;color:rgba(255,255,255,0.3)">→</div>
<div style="width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,#93c5fd,#818cf8);display:flex;align-items:center;justify-content:center;font-size:16px;box-shadow:0 0 10px rgba(147,197,253,0.4)">🌙</div>
</div>
<div style="text-align:center;margin-top:6px">
<div style="font-size:8px;color:rgba(255,255,255,0.5)">Lin · 月夜精灵</div>
<div style="display:flex;justify-content:center;gap:3px;margin-top:3px">
<span style="font-size:7px;color:#fbbf24">★★★★★</span>
</div>
</div>
</div>
</div>
<div style="text-align:center;margin-top:8px;font-size:8px;color:rgba(255,255,255,0.25)">已有 3,241 位用户生成了专属 AI 形象</div>
</div>
<div class="card-body">
<h3>B · 用户前后对比卡</h3>
<p>展示"上传照片 → 生成形象"的对比效果,配用户名和五星好评,增加可信度和转化感,像真实上线产品。</p>
</div>
</div>
</div>
<p style="font-size:11px;color:#94a3b8;text-align:center;margin-top:12px">两者都是 MVP 阶段用 emoji + 渐变模拟效果,无需真实 AI 图片</p>
@@ -1,110 +0,0 @@
<h2>连线页整体布局结构</h2>
<p class="subtitle">上传照片生成 AI 形象的页面,哪种布局更符合你想要的 App 感?</p>
<div class="split" style="gap:20px;margin-top:24px">
<!-- Option A -->
<div class="card" data-choice="a" onclick="toggleSelect(this)" style="cursor:pointer;flex:1;min-width:0">
<div class="card-body" style="padding:0">
<div style="background:#1a0a2e;border-radius:14px;overflow:hidden;font-family:sans-serif">
<!-- Status bar -->
<div style="background:#1a0a2e;padding:6px 14px;display:flex;justify-content:space-between;align-items:center">
<span style="color:#fff;font-size:9px;opacity:0.6">9:41</span>
<span style="color:#fff;font-size:9px;opacity:0.5">●●●</span>
</div>
<!-- Hero -->
<div style="background:linear-gradient(160deg,#3b0764,#1e1b4b);padding:20px 14px 16px;text-align:center">
<div style="font-size:18px;font-weight:800;color:#fff;letter-spacing:0.5px">✨ AI 连线</div>
<div style="font-size:10px;color:rgba(255,255,255,0.55);margin-top:4px">上传照片,生成你的专属 AI 陪伴形象</div>
<div style="display:flex;justify-content:center;gap:8px;margin-top:14px">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#f9a8d4,#a78bfa);display:flex;align-items:center;justify-content:center;font-size:20px">👧</div>
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#93c5fd,#c084fc);display:flex;align-items:center;justify-content:center;font-size:20px">👩</div>
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#86efac,#34d399);display:flex;align-items:center;justify-content:center;font-size:20px">🧑</div>
</div>
<div style="font-size:9px;color:rgba(255,255,255,0.4);margin-top:8px">AI 示例形象 · 点击查看</div>
</div>
<!-- Upload section -->
<div style="background:#fff0f8;padding:14px;margin:10px;border-radius:12px;text-align:center">
<div style="border:2px dashed #f9a8d4;border-radius:10px;padding:18px 10px">
<div style="font-size:24px">📷</div>
<div style="font-size:11px;font-weight:600;color:#1f2937;margin-top:4px">上传你的正脸照片</div>
<div style="font-size:9px;color:#9ca3af;margin-top:2px">JPG / PNG · 5MB 以内</div>
</div>
</div>
<!-- CTA -->
<div style="padding:4px 14px 16px">
<div style="background:linear-gradient(135deg,#f472b6,#a78bfa);border-radius:14px;padding:12px;text-align:center;font-size:11px;font-weight:700;color:#fff">生成 AI 专属形象 ✨</div>
</div>
<!-- Tab bar -->
<div style="background:#1a0a2e;padding:8px 0 4px;display:flex;justify-content:space-around;border-top:1px solid rgba(255,255,255,0.08)">
<div style="text-align:center"><div style="font-size:14px">🔥</div><div style="font-size:8px;color:rgba(255,255,255,0.35)">匹配</div></div>
<div style="text-align:center"><div style="font-size:14px">🌟</div><div style="font-size:8px;color:rgba(255,255,255,0.35)">探索</div></div>
<div style="text-align:center;background:rgba(244,114,182,0.2);border-radius:20px;padding:2px 8px"><div style="font-size:14px">⚡</div><div style="font-size:8px;color:#f9a8d4;font-weight:600">连线</div></div>
<div style="text-align:center"><div style="font-size:14px">💬</div><div style="font-size:8px;color:rgba(255,255,255,0.35)">消息</div></div>
<div style="text-align:center"><div style="font-size:14px">👤</div><div style="font-size:8px;color:rgba(255,255,255,0.35)">我的</div></div>
</div>
</div>
</div>
<div class="card-body" style="padding:12px 8px 0">
<h3 style="font-size:13px">A · 深色渐变 Hero</h3>
<p style="font-size:11px;color:#64748b">顶部暗色沉浸 + 示例头像展示区,整体更像真实 AI 陪伴 App,有强烈的氛围感</p>
</div>
</div>
<!-- Option B -->
<div class="card" data-choice="b" onclick="toggleSelect(this)" style="cursor:pointer;flex:1;min-width:0">
<div class="card-body" style="padding:0">
<div style="background:#fff0f8;border-radius:14px;overflow:hidden;font-family:sans-serif">
<!-- Status bar -->
<div style="background:#fff0f8;padding:6px 14px;display:flex;justify-content:space-between;align-items:center">
<span style="color:#374151;font-size:9px;opacity:0.6">9:41</span>
<span style="color:#374151;font-size:9px;opacity:0.5">●●●</span>
</div>
<!-- Header -->
<div style="padding:14px 14px 10px;text-align:center">
<div style="font-size:11px;font-weight:800;color:#be185d;letter-spacing:1px;text-transform:uppercase">AI 虚拟伴侣</div>
<div style="font-size:18px;font-weight:800;color:#1f2937;margin-top:2px">生成专属形象</div>
<div style="font-size:10px;color:#9ca3af;margin-top:3px">上传正脸,AI 为你定制可爱卡通分身</div>
</div>
<!-- Example row -->
<div style="padding:0 14px 10px">
<div style="background:white;border-radius:12px;padding:10px;display:flex;gap:8px;align-items:center;box-shadow:0 2px 12px rgba(236,72,153,0.1)">
<div style="font-size:9px;color:#9ca3af;white-space:nowrap">示例效果</div>
<div style="display:flex;gap:6px">
<div style="width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,#fde68a,#f59e0b);display:flex;align-items:center;justify-content:center;font-size:16px">🐣</div>
<div style="width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,#fbcfe8,#ec4899);display:flex;align-items:center;justify-content:center;font-size:16px">🌸</div>
<div style="width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,#bfdbfe,#3b82f6);display:flex;align-items:center;justify-content:center;font-size:16px">🌙</div>
<div style="width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,#d9f99d,#22c55e);display:flex;align-items:center;justify-content:center;font-size:16px">🍀</div>
</div>
</div>
</div>
<!-- Upload -->
<div style="padding:0 14px 10px">
<div style="border:2px dashed #f9a8d4;border-radius:14px;padding:20px;text-align:center;background:white">
<div style="width:48px;height:48px;border-radius:50%;background:linear-gradient(135deg,#f9a8d4,#c084fc);margin:0 auto 8px;display:flex;align-items:center;justify-content:center;font-size:22px">📸</div>
<div style="font-size:11px;font-weight:700;color:#1f2937">点击上传正脸照片</div>
<div style="font-size:9px;color:#9ca3af;margin-top:2px">JPG · PNG · 5MB 以内</div>
</div>
</div>
<!-- CTA -->
<div style="padding:0 14px 10px">
<div style="background:linear-gradient(135deg,#f472b6,#a78bfa);border-radius:14px;padding:12px;text-align:center;font-size:11px;font-weight:700;color:#fff">✨ 立即生成 AI 形象</div>
</div>
<!-- Tab bar -->
<div style="background:white;padding:8px 0 4px;display:flex;justify-content:space-around;border-top:1px solid #f3e8ff">
<div style="text-align:center"><div style="font-size:14px">🔥</div><div style="font-size:8px;color:#9ca3af">匹配</div></div>
<div style="text-align:center"><div style="font-size:14px">🌟</div><div style="font-size:8px;color:#9ca3af">探索</div></div>
<div style="text-align:center;background:linear-gradient(135deg,rgba(244,114,182,0.15),rgba(167,139,250,0.15));border-radius:20px;padding:2px 8px"><div style="font-size:14px">⚡</div><div style="font-size:8px;color:#ec4899;font-weight:600">连线</div></div>
<div style="text-align:center"><div style="font-size:14px">💬</div><div style="font-size:8px;color:#9ca3af">消息</div></div>
<div style="text-align:center"><div style="font-size:14px">👤</div><div style="font-size:8px;color:#9ca3af">我的</div></div>
</div>
</div>
</div>
<div class="card-body" style="padding:12px 8px 0">
<h3 style="font-size:13px">B · 浅色奶油风</h3>
<p style="font-size:11px;color:#64748b">粉白系背景 + 清新卡片风格,和当前 App 粉色/奶油色主题一致,示例以小图标行展示</p>
</div>
</div>
</div>
<p style="font-size:11px;color:#94a3b8;text-align:center;margin-top:16px">两种方案都保留底部导航栏,AI 示例形象区域、上传卡片、主按钮都有,区别在于整体氛围</p>
@@ -1,90 +0,0 @@
<h2>视频通话页 · 文字聊天交互方式</h2>
<p class="subtitle">点击"文字聊天"按钮后,如何展示聊天界面?</p>
<div class="cards" style="margin-top:20px">
<!-- Option A: Bottom slide-up sheet -->
<div class="card" data-choice="a" onclick="toggleSelect(this)" style="cursor:pointer">
<div class="card-image" style="padding:0;background:#0f0720;border-radius:12px 12px 0 0;overflow:hidden;position:relative;height:280px;font-family:sans-serif">
<!-- AI Avatar area (blurred behind) -->
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding-bottom:120px">
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#f9a8d4,#a78bfa);display:flex;align-items:center;justify-content:center;font-size:36px;filter:blur(1px);opacity:0.7">👩</div>
<div style="font-size:10px;color:rgba(255,255,255,0.5);margin-top:6px">AI 伴侣</div>
</div>
<!-- User PiP -->
<div style="position:absolute;top:10px;right:10px;width:44px;height:60px;border-radius:10px;background:#2d1a4e;border:1px solid rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px">👤</div>
<!-- Chat sheet slides up -->
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(18,6,40,0.95);backdrop-filter:blur(20px);border-radius:18px 18px 0 0;border-top:1px solid rgba(255,255,255,0.12);padding:10px">
<!-- Chat messages (3 lines) -->
<div style="display:flex;flex-direction:column;gap:5px;max-height:80px;overflow:hidden;margin-bottom:8px">
<div style="display:flex;justify-content:flex-start">
<div style="background:rgba(167,139,250,0.25);border-radius:12px 12px 12px 2px;padding:4px 8px;font-size:9px;color:#e9d5ff;max-width:70%">今天感觉怎么样呀?</div>
</div>
<div style="display:flex;justify-content:flex-end">
<div style="background:linear-gradient(135deg,#f472b6,#a78bfa);border-radius:12px 12px 2px 12px;padding:4px 8px;font-size:9px;color:#fff;max-width:70%">挺好的,想你了</div>
</div>
<div style="display:flex;justify-content:flex-start">
<div style="background:rgba(167,139,250,0.25);border-radius:12px 12px 12px 2px;padding:4px 8px;font-size:9px;color:#e9d5ff;max-width:70%">哇,我也是 💕</div>
</div>
</div>
<!-- Input bar -->
<div style="display:flex;gap:6px;align-items:center">
<div style="flex:1;background:rgba(255,255,255,0.08);border-radius:20px;padding:5px 10px;font-size:9px;color:rgba(255,255,255,0.35)">发送消息...</div>
<div style="width:26px;height:26px;border-radius:50%;background:linear-gradient(135deg,#f472b6,#a78bfa);display:flex;align-items:center;justify-content:center;font-size:12px">↑</div>
</div>
</div>
<!-- Bottom controls (partially visible) -->
<div style="position:absolute;bottom:-10px;left:0;right:0;display:flex;justify-content:space-around;padding:6px 30px;opacity:0.4">
<div style="width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,0.12);display:flex;align-items:center;justify-content:center;font-size:14px">🎤</div>
<div style="width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,0.12);display:flex;align-items:center;justify-content:center;font-size:14px">📷</div>
<div style="width:36px;height:36px;border-radius:50%;background:rgba(236,72,153,0.4);display:flex;align-items:center;justify-content:center;font-size:14px">✉️</div>
<div style="width:48px;height:48px;border-radius:50%;background:#ef4444;display:flex;align-items:center;justify-content:center;font-size:16px">📵</div>
</div>
</div>
<div class="card-body">
<h3>A · 底部上滑面板</h3>
<p>点击聊天按钮后,聊天面板从底部滑上来,半遮挡 AI 形象,仍可看到背景。再次点击收起。</p>
</div>
</div>
<!-- Option B: Floating overlay with minimize -->
<div class="card" data-choice="b" onclick="toggleSelect(this)" style="cursor:pointer">
<div class="card-image" style="padding:0;background:#0f0720;border-radius:12px 12px 0 0;overflow:hidden;position:relative;height:280px;font-family:sans-serif">
<!-- AI Avatar still centered -->
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding-bottom:60px">
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#f9a8d4,#a78bfa);display:flex;align-items:center;justify-content:center;font-size:36px">👩</div>
<div style="font-size:10px;color:rgba(255,255,255,0.6);margin-top:6px">AI 伴侣 · 通话中</div>
<!-- Speaking wave -->
<div style="display:flex;gap:2px;align-items:flex-end;margin-top:6px;height:12px">
<div style="width:3px;background:#f9a8d4;border-radius:2px;height:5px"></div>
<div style="width:3px;background:#f9a8d4;border-radius:2px;height:10px"></div>
<div style="width:3px;background:#f9a8d4;border-radius:2px;height:7px"></div>
<div style="width:3px;background:#f9a8d4;border-radius:2px;height:12px"></div>
<div style="width:3px;background:#f9a8d4;border-radius:2px;height:6px"></div>
</div>
</div>
<!-- User PiP -->
<div style="position:absolute;top:10px;right:10px;width:44px;height:60px;border-radius:10px;background:#2d1a4e;border:1px solid rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px">👤</div>
<!-- Quick input bar at very bottom (always visible) -->
<div style="position:absolute;bottom:48px;left:10px;right:10px;background:rgba(255,255,255,0.07);border-radius:20px;padding:5px 12px;display:flex;align-items:center;gap:6px;border:1px solid rgba(255,255,255,0.1)">
<span style="font-size:9px;color:rgba(255,255,255,0.35);flex:1">说点什么...</span>
<span style="font-size:11px">✉️</span>
</div>
<!-- Bottom controls -->
<div style="position:absolute;bottom:0;left:0;right:0;display:flex;justify-content:space-around;align-items:center;padding:6px 20px">
<div style="width:34px;height:34px;border-radius:50%;background:rgba(255,255,255,0.12);display:flex;align-items:center;justify-content:center;font-size:13px">🔇</div>
<div style="width:34px;height:34px;border-radius:50%;background:rgba(255,255,255,0.12);display:flex;align-items:center;justify-content:center;font-size:13px">📷</div>
<div style="width:48px;height:48px;border-radius:50%;background:#ef4444;display:flex;align-items:center;justify-content:center;font-size:16px">📵</div>
<div style="width:34px;height:34px;border-radius:50%;background:rgba(255,255,255,0.12);display:flex;align-items:center;justify-content:center;font-size:13px">🔊</div>
<div style="width:34px;height:34px;border-radius:50%;background:rgba(255,255,255,0.12);display:flex;align-items:center;justify-content:center;font-size:13px">✉️</div>
</div>
</div>
<div class="card-body">
<h3>B · 底部常驻输入栏</h3>
<p>文字输入框始终显示在底部控制栏上方,点击输入栏后键盘弹出直接输入,不遮挡主视图。</p>
</div>
</div>
</div>
<p style="font-size:11px;color:#94a3b8;text-align:center;margin-top:12px">两种方案底部控制按钮都包含:静音 · 摄像头 · 挂断(红色)· 扬声器 · 文字聊天</p>
@@ -1,3 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -1,3 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -1,3 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -1 +0,0 @@
{"reason":"owner process exited","timestamp":1778557038236}
@@ -1 +0,0 @@
3561746
@@ -1,66 +0,0 @@
<h2>好友系统:整体架构方向</h2>
<p class="subtitle">请选择你倾向的实现方案,我们会根据你的选择来设计后续细节</p>
<div class="section">
<p class="label">背景说明</p>
<p style="font-size:13px;color:var(--text-secondary);margin:8px 0 16px;">当前消息页展示的是 AI 角色聊天(虚拟人物)。好友系统会引入真实用户之间的互动。两套系统需要共存。</p>
</div>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>独立好友 Tab(推荐)</h3>
<p>新增一个"好友"Tab(底部导航),与现有"消息"Tab(AI 聊天)完全分开。好友系统有自己的入口、页面和数据流。</p>
<div class="pros-cons" style="margin-top:10px;">
<div class="pros"><h4>优点</h4><ul>
<li>AI 聊天和真实好友不混淆</li>
<li>结构清晰,好维护</li>
<li>不动现有消息页代码</li>
</ul></div>
<div class="cons"><h4>缺点</h4><ul>
<li>底部多一个 Tab,空间稍紧</li>
<li>用户需要切换 Tab 才能找好友</li>
</ul></div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>融入消息 Tab(统一收件箱)</h3>
<p>消息页上方增加"AI 聊天 / 好友"切换分段,下方列表根据 Tab 切换。右上角 "+" 按钮打开好友管理弹出面板。</p>
<div class="pros-cons" style="margin-top:10px;">
<div class="pros"><h4>优点</h4><ul>
<li>所有聊天入口统一</li>
<li>底部 Tab 不增加</li>
<li>像微信/Soul 的设计</li>
</ul></div>
<div class="cons"><h4>缺点</h4><ul>
<li>要改现有消息页(稍复杂)</li>
<li>AI 聊天和真实聊天并排,概念略混</li>
</ul></div>
</div>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>消息 Tab 内嵌好友入口</h3>
<p>消息页顶部固定一个"好友"快捷入口卡片(含申请红点),下方依然是 AI 聊天列表。好友管理页面是独立的子页面。</p>
<div class="pros-cons" style="margin-top:10px;">
<div class="pros"><h4>优点</h4><ul>
<li>改动最小,不影响现有 AI 聊天</li>
<li>有明显入口,不会找不到</li>
<li>好友单独页面更专注</li>
</ul></div>
<div class="cons"><h4>缺点</h4><ul>
<li>消息页顶部多一块卡片,稍挤</li>
<li>好友列表需要额外一次点击</li>
</ul></div>
</div>
</div>
</div>
</div>
@@ -1,69 +0,0 @@
<h2>好友入口卡片 & 子页面结构</h2>
<p class="subtitle">消息页顶部的好友入口长什么样?点击后怎么进入好友管理?</p>
<div class="cards">
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="padding:16px;background:linear-gradient(135deg,#1a0a12,#2a0e1e);border-radius:12px 12px 0 0;">
<!-- 消息页顶部模拟 -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<span style="color:#fff;font-size:15px;font-weight:700;">消息</span>
<span style="color:#ff6496;font-size:20px;">+</span>
</div>
<!-- 好友入口卡片 -->
<div style="background:rgba(255,100,150,0.12);border:1px solid rgba(255,100,150,0.25);border-radius:14px;padding:12px 14px;display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<div style="width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#ff6496,#a970ff);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;">👥</div>
<div style="flex:1;">
<div style="color:#fff;font-size:13px;font-weight:600;">好友</div>
<div style="color:rgba(255,255,255,0.5);font-size:11px;">3 位好友 · 1 条新申请</div>
</div>
<div style="background:#ff6496;border-radius:10px;padding:2px 8px;color:#fff;font-size:11px;font-weight:700;">1</div>
</div>
<!-- AI聊天列表 -->
<div style="background:rgba(255,255,255,0.05);border-radius:10px;padding:10px 12px;display:flex;gap:10px;align-items:center;">
<div style="width:38px;height:38px;border-radius:50%;background:#3a1a2a;flex-shrink:0;"></div>
<div style="flex:1;"><div style="background:rgba(255,255,255,0.15);height:10px;border-radius:5px;width:60%;margin-bottom:6px;"></div><div style="background:rgba(255,255,255,0.08);height:8px;border-radius:4px;width:80%;"></div></div>
</div>
</div>
<div class="card-body">
<h3>A — 单行入口卡片(推荐)</h3>
<p>消息页顶部一行卡片,显示好友数和未读申请角标。点击 → 进入好友列表页(包含底部标签:我的好友 / 申请 / 搜索)</p>
</div>
</div>
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="padding:16px;background:linear-gradient(135deg,#1a0a12,#2a0e1e);border-radius:12px 12px 0 0;">
<!-- 消息页顶部模拟 -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<span style="color:#fff;font-size:15px;font-weight:700;">消息</span>
<span style="color:#ff6496;font-size:20px;">+</span>
</div>
<!-- 三个快捷按钮 -->
<div style="display:flex;gap:8px;margin-bottom:10px;">
<div style="flex:1;background:rgba(255,100,150,0.12);border:1px solid rgba(255,100,150,0.25);border-radius:12px;padding:10px;text-align:center;">
<div style="font-size:20px;margin-bottom:4px;">🔍</div>
<div style="color:#fff;font-size:10px;">搜索好友</div>
</div>
<div style="flex:1;background:rgba(169,112,255,0.12);border:1px solid rgba(169,112,255,0.25);border-radius:12px;padding:10px;text-align:center;position:relative;">
<div style="font-size:20px;margin-bottom:4px;">📩</div>
<div style="color:#fff;font-size:10px;">好友申请</div>
<div style="position:absolute;top:6px;right:6px;background:#ff6496;border-radius:8px;padding:1px 5px;color:#fff;font-size:9px;font-weight:700;">1</div>
</div>
<div style="flex:1;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:10px;text-align:center;">
<div style="font-size:20px;margin-bottom:4px;">👥</div>
<div style="color:#fff;font-size:10px;">我的好友</div>
</div>
</div>
<!-- AI聊天列表 -->
<div style="background:rgba(255,255,255,0.05);border-radius:10px;padding:10px 12px;display:flex;gap:10px;align-items:center;">
<div style="width:38px;height:38px;border-radius:50%;background:#3a1a2a;flex-shrink:0;"></div>
<div style="flex:1;"><div style="background:rgba(255,255,255,0.15);height:10px;border-radius:5px;width:60%;margin-bottom:6px;"></div><div style="background:rgba(255,255,255,0.08);height:8px;border-radius:4px;width:80%;"></div></div>
</div>
</div>
<div class="card-body">
<h3>B — 三格快捷入口</h3>
<p>顶部三个图标格:搜索好友 / 好友申请(带角标)/ 我的好友,点击各自跳对应页。占地稍多但直达。</p>
</div>
</div>
</div>
@@ -1,130 +0,0 @@
<h2>好友子页面布局</h2>
<p class="subtitle">三个独立页面:搜索好友 / 好友申请 / 我的好友,各自长什么样?</p>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-top:8px;">
<!-- 搜索好友页 -->
<div>
<p class="label" style="margin-bottom:8px;">搜索好友</p>
<div style="background:linear-gradient(135deg,#1a0a12,#2a0e1e);border-radius:16px;padding:14px;min-height:320px;">
<!-- 顶栏 -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="color:rgba(255,255,255,0.5);font-size:18px;">←</span>
<span style="color:#fff;font-size:14px;font-weight:700;">搜索好友</span>
</div>
<!-- 搜索框 -->
<div style="background:rgba(255,255,255,0.08);border-radius:20px;padding:9px 14px;display:flex;align-items:center;gap:8px;margin-bottom:14px;">
<span style="color:rgba(255,255,255,0.4);font-size:14px;">🔍</span>
<span style="color:rgba(255,255,255,0.3);font-size:13px;">搜索昵称 / 用户ID</span>
</div>
<!-- 搜索结果卡片 -->
<div style="background:rgba(255,255,255,0.05);border-radius:14px;padding:12px;display:flex;gap:10px;align-items:center;margin-bottom:8px;">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#ff8fbc,#a970ff);flex-shrink:0;"></div>
<div style="flex:1;">
<div style="color:#fff;font-size:13px;font-weight:600;margin-bottom:3px;">小鹿鹿</div>
<div style="color:rgba(255,255,255,0.4);font-size:11px;">上海 · 热爱生活的设计师</div>
</div>
<div style="background:linear-gradient(135deg,#ff6496,#e8407a);border-radius:10px;padding:5px 10px;color:#fff;font-size:11px;font-weight:700;">添加</div>
</div>
<div style="background:rgba(255,255,255,0.05);border-radius:14px;padding:12px;display:flex;gap:10px;align-items:center;">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#ffd6a5,#ff8fbc);flex-shrink:0;"></div>
<div style="flex:1;">
<div style="color:#fff;font-size:13px;font-weight:600;margin-bottom:3px;">暖暖向阳</div>
<div style="color:rgba(255,255,255,0.4);font-size:11px;">北京 · 摄影师</div>
</div>
<div style="background:rgba(255,255,255,0.08);border-radius:10px;padding:5px 10px;color:rgba(255,255,255,0.4);font-size:11px;">等待验证</div>
</div>
</div>
</div>
<!-- 好友申请页 -->
<div>
<p class="label" style="margin-bottom:8px;">好友申请</p>
<div style="background:linear-gradient(135deg,#1a0a12,#2a0e1e);border-radius:16px;padding:14px;min-height:320px;">
<!-- 顶栏 -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="color:rgba(255,255,255,0.5);font-size:18px;">←</span>
<span style="color:#fff;font-size:14px;font-weight:700;">好友申请</span>
<span style="background:#ff6496;border-radius:10px;padding:1px 7px;color:#fff;font-size:10px;font-weight:700;margin-left:2px;">2</span>
</div>
<!-- 申请卡片 -->
<div style="background:rgba(255,255,255,0.05);border-radius:14px;padding:12px;margin-bottom:8px;">
<div style="display:flex;gap:10px;align-items:center;margin-bottom:10px;">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#a970ff,#6040ff);flex-shrink:0;"></div>
<div style="flex:1;">
<div style="color:#fff;font-size:13px;font-weight:600;">豆豆不加糖</div>
<div style="color:rgba(255,255,255,0.4);font-size:11px;">成都 · 3分钟前</div>
</div>
</div>
<div style="color:rgba(255,255,255,0.5);font-size:11px;margin-bottom:10px;padding:6px 8px;background:rgba(255,255,255,0.04);border-radius:8px;">我在社区看到你了,想认识一下~</div>
<div style="display:flex;gap:8px;">
<div style="flex:1;background:rgba(255,255,255,0.08);border-radius:10px;padding:7px;text-align:center;color:rgba(255,255,255,0.5);font-size:12px;">拒绝</div>
<div style="flex:1;background:linear-gradient(135deg,#ff6496,#e8407a);border-radius:10px;padding:7px;text-align:center;color:#fff;font-size:12px;font-weight:700;">同意</div>
</div>
</div>
<!-- 空状态提示 -->
<div style="text-align:center;padding:20px 0;color:rgba(255,255,255,0.25);font-size:12px;">暂无更多申请</div>
</div>
</div>
<!-- 我的好友页 -->
<div>
<p class="label" style="margin-bottom:8px;">我的好友</p>
<div style="background:linear-gradient(135deg,#1a0a12,#2a0e1e);border-radius:16px;padding:14px;min-height:320px;">
<!-- 顶栏 -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="color:rgba(255,255,255,0.5);font-size:18px;">←</span>
<span style="color:#fff;font-size:14px;font-weight:700;">我的好友 <span style="color:rgba(255,255,255,0.4);font-size:12px;font-weight:400;">3</span></span>
</div>
<!-- 好友列表 -->
<div style="display:flex;flex-direction:column;gap:8px;">
<div style="background:rgba(255,255,255,0.05);border-radius:14px;padding:11px 12px;display:flex;gap:10px;align-items:center;">
<div style="position:relative;flex-shrink:0;">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#ff8fbc,#ff6496);"></div>
<div style="position:absolute;bottom:0;right:0;width:11px;height:11px;background:#4ade80;border-radius:50%;border:2px solid #1a0a12;"></div>
</div>
<div style="flex:1;">
<div style="color:#fff;font-size:13px;font-weight:600;margin-bottom:2px;">小鹿鹿</div>
<div style="color:rgba(255,255,255,0.4);font-size:11px;">刚刚活跃</div>
</div>
<div style="background:rgba(255,100,150,0.15);border:1px solid rgba(255,100,150,0.3);border-radius:10px;padding:5px 10px;color:#ff6496;font-size:11px;">发消息</div>
</div>
<div style="background:rgba(255,255,255,0.05);border-radius:14px;padding:11px 12px;display:flex;gap:10px;align-items:center;">
<div style="position:relative;flex-shrink:0;">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#a970ff,#6040ff);"></div>
</div>
<div style="flex:1;">
<div style="color:#fff;font-size:13px;font-weight:600;margin-bottom:2px;">暖暖向阳</div>
<div style="color:rgba(255,255,255,0.4);font-size:11px;">1小时前</div>
</div>
<div style="background:rgba(255,100,150,0.15);border:1px solid rgba(255,100,150,0.3);border-radius:10px;padding:5px 10px;color:#ff6496;font-size:11px;">发消息</div>
</div>
<div style="background:rgba(255,255,255,0.05);border-radius:14px;padding:11px 12px;display:flex;gap:10px;align-items:center;">
<div style="position:relative;flex-shrink:0;">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#ffd6a5,#ff8fbc);"></div>
</div>
<div style="flex:1;">
<div style="color:#fff;font-size:13px;font-weight:600;margin-bottom:2px;">风里来</div>
<div style="color:rgba(255,255,255,0.4);font-size:11px;">昨天</div>
</div>
<div style="background:rgba(255,100,150,0.15);border:1px solid rgba(255,100,150,0.3);border-radius:10px;padding:5px 10px;color:#ff6496;font-size:11px;">发消息</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:20px;">
<p style="font-size:13px;color:var(--text-secondary);">以上是三个子页面的 UI 预览,整体风格与现有 APP 一致(深色+粉紫渐变)。如果你对某个页面的布局有想法,可以直接告诉我。</p>
<div class="options" style="margin-top:12px;">
<div class="option" data-choice="ok" onclick="toggleSelect(this)">
<div class="letter">✓</div>
<div class="content"><h3>整体方向对,继续设计数据库方案</h3><p>接下来讨论表结构和 API 细节</p></div>
</div>
<div class="option" data-choice="adjust" onclick="toggleSelect(this)">
<div class="letter">✎</div>
<div class="content"><h3>有些地方想调整</h3><p>我来告诉你哪里改一改</p></div>
</div>
</div>
</div>
@@ -1,83 +0,0 @@
<h2>技术方案总览</h2>
<p class="subtitle">数据库表结构 · API 路由 · 文件结构</p>
<div class="section">
<h3>数据库(Supabase)</h3>
<p class="label">复用已有表 + 新增 2 张表</p>
<div style="background:rgba(255,255,255,0.04);border-radius:12px;padding:14px;margin-top:10px;font-family:monospace;font-size:12px;line-height:1.8;color:var(--text-secondary);">
<div style="color:#4ade80;margin-bottom:6px;">-- 已有,不需要修改</div>
<div>public.<span style="color:#f9a8d4;">user_profiles</span> ← 搜索用户、展示资料用</div>
<br>
<div style="color:#4ade80;margin-bottom:6px;">-- 新增 1:好友申请表</div>
<div>public.<span style="color:#f9a8d4;">friend_requests</span></div>
<div style="padding-left:16px;color:rgba(255,255,255,0.5);">
id uuid PK<br>
from_user_id uuid → auth.users<br>
to_user_id uuid → auth.users<br>
status text (pending/accepted/rejected)<br>
message text (申请备注)<br>
created_at timestamptz<br>
updated_at timestamptz<br>
UNIQUE(from_user_id, to_user_id)
</div>
<br>
<div style="color:#4ade80;margin-bottom:6px;">-- 新增 2:好友关系表(双向)</div>
<div>public.<span style="color:#f9a8d4;">friends</span></div>
<div style="padding-left:16px;color:rgba(255,255,255,0.5);">
user_id uuid → auth.users<br>
friend_id uuid → auth.users<br>
created_at timestamptz<br>
PRIMARY KEY(user_id, friend_id)
</div>
</div>
</div>
<div class="section">
<h3>API 路由(Next.js App Router)</h3>
<div style="background:rgba(255,255,255,0.04);border-radius:12px;padding:14px;margin-top:10px;font-family:monospace;font-size:12px;line-height:2;color:var(--text-secondary);">
<div><span style="color:#93c5fd;">GET</span> /api/friends/search?q=昵称 搜索用户</div>
<div><span style="color:#86efac;">POST</span> /api/friends/requests 发送好友申请</div>
<div><span style="color:#93c5fd;">GET</span> /api/friends/requests 收到的申请列表</div>
<div><span style="color:#86efac;">POST</span> /api/friends/requests/[id]/accept 同意申请</div>
<div><span style="color:#86efac;">POST</span> /api/friends/requests/[id]/reject 拒绝申请</div>
<div><span style="color:#93c5fd;">GET</span> /api/friends 我的好友列表</div>
</div>
</div>
<div class="section">
<h3>前端文件结构</h3>
<div style="background:rgba(255,255,255,0.04);border-radius:12px;padding:14px;margin-top:10px;font-family:monospace;font-size:12px;line-height:2;color:var(--text-secondary);">
<div style="color:rgba(255,255,255,0.7);">app/friends/</div>
<div style="padding-left:16px;">
<span style="color:#f9a8d4;">search/page.tsx</span> 搜索好友页<br>
<span style="color:#f9a8d4;">requests/page.tsx</span> 好友申请页<br>
<span style="color:#f9a8d4;">list/page.tsx</span> 我的好友列表页
</div>
<br>
<div style="color:rgba(255,255,255,0.7);">lib/backend/</div>
<div style="padding-left:16px;">
<span style="color:#f9a8d4;">friends-repository.ts</span> 所有好友相关 DB 操作
</div>
<br>
<div style="color:rgba(255,255,255,0.7);">supabase/</div>
<div style="padding-left:16px;">
<span style="color:#f9a8d4;">friends-schema.sql</span> 新表 + RLS 策略
</div>
</div>
</div>
<div class="section">
<h3>消息页改动</h3>
<p style="font-size:13px;color:var(--text-secondary);">在 <code>page.tsx</code> 的 MessagesView 组件顶部,插入三格快捷入口(搜索好友 / 好友申请 / 我的好友),用 Next.js router.push() 跳转到对应子页面。</p>
</div>
<div class="options" style="margin-top:8px;">
<div class="option" data-choice="ok" onclick="toggleSelect(this)">
<div class="letter">✓</div>
<div class="content"><h3>方案确认,写设计文档</h3><p>开始输出完整设计 doc,然后进入实现计划阶段</p></div>
</div>
<div class="option" data-choice="adjust" onclick="toggleSelect(this)">
<div class="letter">✎</div>
<div class="content"><h3>有问题想先讨论</h3><p>告诉我哪里需要调整</p></div>
</div>
</div>
@@ -1 +0,0 @@
{"reason":"idle timeout","timestamp":1778646633094}
@@ -1 +0,0 @@
3885103
-47
View File
@@ -1,47 +0,0 @@
FROM node:22-slim AS deps
WORKDIR /app
# Build tools required for better-sqlite3 native addon
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
# ── Build stage ───────────────────────────────────────────────────
FROM node:22-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production \
ADMIN_PASSWORD=build-placeholder \
ADMIN_SESSION_SECRET=build-placeholder
RUN npm run build
# ── Runtime stage ─────────────────────────────────────────────────
FROM node:22-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production PORT=3000
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
RUN mkdir -p _server-storage
EXPOSE 3000
CMD ["npm", "start"]
-251
View File
@@ -1,251 +0,0 @@
# MatchU 心遇项目计划书(整理版)
整理日期:2026-04-29
源项目目录:`G:\工作内容\matchu-vps-deploy`
目标用途:项目归档、交付说明、VPS 部署前沟通与后续迭代规划
## 1. 项目概况
MatchU(心遇)是一款以 AI 性格测评驱动的真人交友与陪伴产品。当前项目已经从最初的 Mock AI 演示,扩展到包含匹配推荐、消息列表、虚拟视频、社区树洞、用户资料、后台运营和 VPS 部署能力的 Web/PWA 项目。
- 项目名称:MatchU · 心遇
- 产品方向:AI 性格测评 + 智能匹配 + AI 助聊 + 社区互动 + 虚拟陪伴
- 当前形态:Next.js Web App / PWA
- 在线演示:`https://matchu-nine.vercel.app`
- 当前部署目标:VPS 独立部署,推荐路径为 `/root/matchu`
- 技术基线:Next.js 16、React 19、TypeScript、Tailwind CSS 4、Zustand、Framer Motion、Supabase
## 2. 项目目标
当前阶段的核心目标是把 MatchU 从“可展示的前端原型”推进到“可部署、可运营、可演示给真实用户体验”的版本。
主要目标包括:
- 验证“性格测评 -> 生成画像 -> 匹配推荐 -> 聊天破冰”的核心体验。
- 用 Mock AI 和脚本化回复降低早期接入真实 LLM 的成本。
- 搭建可持续运营的后台,让匹配资料、聊天人设、关键词脚本、用户和社区帖子可以被管理。
- 支持 Supabase 数据库、Auth 和 Storage,形成真实用户资料、发帖、点赞、头像/图片上传能力。
- 支持 VPS 部署,配合 PM2 和 Nginx 做长期运行。
## 3. 当前完成状态
### Phase 1:MVP 基础能力(已完成)
- M1 项目骨架:Next.js App Router、全局样式、PWA 基础、状态管理、公共组件。
- M2 AI 性格测评:8 道题、标签打分、流式点评、画像生成、本地持久化。
- M3 AI 助聊聊天页:小雨人设、关键词剧本、打字效果、AI 助聊建议、契合度展示。
- M4 PWA 与 Vercel 演示:应用图标、manifest、错误页、部署说明。
### Phase 2:主应用体验扩展(已完成)
- M5 TabBar 与路由重构:形成匹配、社区/工具、虚拟、消息、我的等主入口。
- M6 匹配推荐屏:多用户卡片、滑卡、喜欢/跳过/超级喜欢动效。
- M7 消息列表屏:契合度展示、会话入口、头像 story 样式列表。
- M8 虚拟视频屏:虚拟人物视频感界面、上传/生成流程、通话弹层。
- M9 我的与会员:个人中心、登录入口、会员权益、设置项。
- M10 AI 工具占位:多款 AI 工具入口占位,为后续商业化/增值功能预留。
### 当前代码包新增能力(以代码为准)
README 中仍保留了一些早期口径,例如“真实用户数据库/登录注册后续再做”。但从当前代码、API 和 `supabase/schema.sql` 看,项目已经加入了以下真实后端能力:
- Supabase Auth 邮箱注册/登录、session 持久化与刷新。
- 用户资料读取、创建、编辑,头像上传。
- 社区帖子列表、发布、图片上传、点赞。
- 匹配资料、聊天人设、聊天脚本可从 Supabase 读取;未配置时回退到本地 mock 数据。
- 运营后台 `/admin/login` 与 `/admin`,可管理匹配资料、聊天人设、关键词脚本、用户资料和社区帖子。
- Supabase Storage bucket:`profile-photos`、`user-avatars`、`post-images`。
- VPS 部署说明、环境变量模板和本地/Supabase 存储切换方案。
## 4. 功能范围
### 用户侧功能
- 欢迎/启动界面:进入主应用。
- 智能匹配:按契合度展示匹配用户,可滑卡和查看详情。
- 社区树洞:浏览社区帖子;登录后可发布文字、心情和最多 3 张图片。
- AI 工具:当前为工具入口占位,后续可扩展为真实工具。
- 虚拟陪伴:上传/生成虚拟形象流程、视频通话感 UI。
- 消息与聊天:查看会话列表、进入聊天弹层、AI 回复尝试。
- 我的页面:邮箱登录/注册、资料编辑、头像上传、会员/设置/隐私等入口。
### 运营后台功能
- 后台登录:使用 `.env.local` 中的 `ADMIN_PASSWORD`。
- 数据初始化:一键 seed 本地 mock 资料到 Supabase。
- 匹配资料管理:维护前台推荐卡片的展示信息、照片、标签、契合度、发布状态。
- 聊天人设管理:维护聊天对象、开场白、兜底回复、关联匹配资料。
- 关键词脚本管理:按关键词、情绪、回合范围管理聊天回复脚本。
- 用户管理:查看已注册用户资料。
- 帖子管理:查看、隐藏/恢复、删除社区帖子。
### 后端与数据功能
- `profiles`:匹配推荐资料。
- `chat_personas`:聊天人设。
- `chat_scripts`:关键词和情绪触发脚本。
- `user_profiles`:真实用户资料。
- `community_posts`:社区帖子。
- `post_likes`:帖子点赞记录。
- Row Level Security:公开读取已发布内容,用户只能维护自己的资料、帖子和点赞。
- Storage:头像、帖子图片和资料照片的公开存储。
## 5. 技术架构
### 前端
- Next.js 16 App Router
- React 19
- TypeScript
- Tailwind CSS 4
- Framer Motion 动效
- Lucide React 图标
- Zustand 本地状态与持久化
### 后端与数据
- Supabase REST:业务数据读写。
- Supabase Auth:邮箱注册/登录、Bearer token 鉴权。
- Supabase Storage:头像、帖子图片、资料照片。
- 本地 fallback:Supabase 未配置时,匹配资料和帖子会回退到 mock 数据,保证演示不完全中断。
### 部署
- 推荐 Node.js 20 或更高版本。
- VPS 目录建议:`/root/matchu`
- 构建命令:`npm ci`、`npm run build`
- 启动命令:`npm run start`
- 长期运行:PM2
- 反向代理:Nginx -> `http://127.0.0.1:3000`
## 6. 部署计划
### 准备阶段
- 准备 VPS,安装 Node.js 20+、npm、PM2、Nginx。
- 准备 Supabase 项目。
- 上传 `G:\工作内容\matchu-vps-deploy` 文件夹内容到 VPS 的 `/root/matchu`。
### 数据库阶段
- 在 Supabase SQL Editor 中执行 `supabase/schema.sql`。
- 确认数据表、索引、RLS 策略和 Storage buckets 创建成功。
- 在后台首次登录后执行 seed,将默认匹配资料和小雨人设脚本写入 Supabase。
### 环境变量阶段
复制 `.env.vps.example` 为 `.env.local`,至少填写:
```env
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
ADMIN_PASSWORD=
ADMIN_SESSION_SECRET=
SERVER_STORAGE_DRIVER=auto
```
### 构建与启动阶段
```bash
npm ci
npm run build
npm run start
```
长期运行建议:
```bash
npm install -g pm2
pm2 start npm --name matchu -- start
pm2 save
pm2 startup
```
### 域名与访问阶段
- Nginx 将域名反代到 `127.0.0.1:3000`。
- 配置 HTTPS 证书。
- 后台入口:`/admin/login`。
- 前台入口:站点根路径 `/`。
## 7. 验收清单
### 前台验收
- 首页能正常进入应用。
- 匹配页能展示资料卡,照片正常加载,滑卡动效正常。
- 社区页能展示帖子;登录后可发帖、上传图片、点赞。
- 消息页可进入聊天弹层,聊天流程不报错。
- 我的页可注册/登录、编辑资料、上传头像。
- 虚拟页和工具页能正常展示,不出现白屏。
- 移动端尺寸下底部导航、弹层、按钮无明显遮挡。
### 后台验收
- `/admin/login` 可使用环境变量中的密码登录。
- `/admin` 首页统计数据正常。
- 可 seed 初始资料。
- 匹配资料、聊天人设、聊天脚本可新增/编辑/删除或上下架。
- 用户和帖子列表可读取,帖子可隐藏或删除。
### 部署验收
- `npm run build` 成功。
- PM2 中 `matchu` 进程在线。
- `pm2 logs matchu` 无持续报错。
- Nginx 反代访问正常。
- Supabase 数据读写、Auth 登录、Storage 上传均正常。
## 8. 后续迭代计划
### 优先级 P0:上线前整理
- 修正 README 中与当前代码不一致的说明,补充 Supabase/Auth/社区/后台能力。
- 补充生产环境 `.env.local` 配置检查表。
- 在 VPS 上完成一次从空库到 seed、构建、访问的完整演练。
- 增加基础错误日志和运维排查说明。
- 明确图片上传走 Supabase Storage 还是 VPS 本地磁盘。
### 优先级 P1:真实用户内测
- 完善邮箱确认、找回密码、退出登录、登录失败提示。
- 增强用户资料完整度、匹配偏好和心动记录。
- 社区增加内容审核策略、敏感词、举报与后台处理。
- 聊天记录从本地/前端状态逐步迁移到后端。
- 加入基础埋点:注册转化、发帖率、匹配点击、聊天启动率。
### 优先级 P2:真实 AI 能力
- 将 `AI_PROVIDER=mock` 切换到真实 LLM provider。
- 补齐真实 AI provider 实现,避免把 API key 写在前端。
- 为 AI 回复增加安全边界、角色设定、敏感内容处理和成本控制。
- 将聊天脚本与 LLM 混合使用:脚本保证人设稳定,LLM 提供灵活回复。
### 优先级 P3:商业化与合规
- 会员支付从当前 UI 占位升级为真实订单与支付回调。
- 增加用户协议、隐私政策、内容规范。
- 按上线渠道处理备案、域名、短信/邮箱服务、数据安全要求。
- 评估微信小程序或 App 包装版本。
## 9. 风险与注意事项
- README 存在旧信息,需要以当前代码和本整理版为准。
- `app/page.tsx` 中存在一个 Gemini 调用示例,但 API key 为空;上线前需要确认是否保留、移除或改为后端代理。
- 目前未看到自动化测试配置,上线前至少需要一轮完整手动回归。
- Supabase Service Role Key 只能放在服务端环境变量中,不应暴露到浏览器端。
- 图片上传默认可走 Supabase Storage;如改用 VPS 本地磁盘,需要确保目录权限、备份和公网访问策略。
- 社区、交友和 AI 陪伴类产品涉及内容安全、隐私保护和合规审核,正式运营前必须补齐治理机制。
- 支付、会员、实名认证、实时音视频当前主要是 UI 或规划层能力,不能按生产功能对外承诺。
## 10. 当前交付物
- 项目代码包:`G:\工作内容\matchu-vps-deploy`
- VPS 部署说明:`G:\工作内容\matchu-vps-deploy\VPS_DEPLOY.md`
- 数据库脚本:`G:\工作内容\matchu-vps-deploy\supabase\schema.sql`
- 环境变量模板:`G:\工作内容\matchu-vps-deploy\.env.vps.example`
- 原始设计/计划资料:`G:\工作内容\matchu-vps-deploy\docs\superpowers`
- 远期立项参考:`G:\工作内容\matchu-vps-deploy\reference\AI虚拟陪伴社交平台_项目立项书_V2_AI工具箱扩展版.docx`
+1 -168
View File
@@ -1,169 +1,2 @@
# MatchU · 心遇
# MatchU--
🌐 **在线演示**:https://matchu-nine.vercel.app
AI 性格测评驱动的真人交友/匹配产品(MVP 阶段 · Mock AI 版)。
## 当前状态
### Phase 1 ✅
- [x] 产品设计:[docs/superpowers/specs/2026-04-23-matchu-mvp-design.md](docs/superpowers/specs/2026-04-23-matchu-mvp-design.md)
- [x] 实施计划:[docs/superpowers/plans/2026-04-23-matchu-mvp-plan.md](docs/superpowers/plans/2026-04-23-matchu-mvp-plan.md)
- [x] M1 项目骨架
- [x] M2 AI 性格测评
- [x] M3 AI 助聊聊天页
- [x] M4 PWA + Vercel 部署
### Phase 2 ✅
- [x] 产品设计:[docs/superpowers/specs/2026-04-23-matchu-phase2-design.md](docs/superpowers/specs/2026-04-23-matchu-phase2-design.md)
- [x] 实施计划:[docs/superpowers/plans/2026-04-23-matchu-phase2-plan.md](docs/superpowers/plans/2026-04-23-matchu-phase2-plan.md)
- [x] M5 TabBar + 路由重构
- [x] M6 匹配推荐屏(5 Mock 用户 + 滑卡)
- [x] M7 消息列表屏(契合度 hero + 6 条聊天)
- [x] M8 虚拟视频屏(樱花场景 + 花瓣动画)
- [x] M9 我的屏 + SVIP 卡
- [x] M10 AI 工具占位页(12 款)
- [x] 烛光暖调主题 + 质感升级(italic Serif 标题 / 玻璃方块图标 / 分区渐隐线 / 双色图标底)
## 主要路由
**沉浸流**(无 TabBar)
- `/` — 首页(欢迎 + 开始测评 / 已测评→/match)
- `/onboard` — AI 性格测评(8 道题 · 流式点评 · 生成性格画像)
- `/chat/xiaoyu` — 和 Mock 真人"小雨"聊天 + AI 助聊副驾驶
**5-Tab 主流**(底部 TabBar)
- `/match` — 智能匹配推荐(5 个 Mock 假人 · 卡片堆叠 · 拖拽滑卡)
- `/messages` — 契合度 hero 卡 + AI 助聊提示 + 最近聊天列表
- `/tools` — AI 工具占位页(12 款 · Phase 3 开放)
- `/virtual` — 虚拟人物视频(樱花场景 + 动漫少女 + 花瓣飘落)
- `/me` — 我的 + SVIP 卡 + 4 菜单项
## 本地开发
```bash
# 首次克隆后
npm install
cp .env.example .env.local
# 启动开发服务器(http://localhost:3000)
npm run dev
# 生产构建 + 预览
npm run build
npm run start
```
## 技术栈
- **框架**:Next.js 16 (App Router) + React 19 + TypeScript
- **样式**:Tailwind CSS 4 + 自定义 CSS 变量(渐变/玻璃态)
- **状态**:Zustand + `persist` 中间件(localStorage)
- **动画**:Framer Motion
- **图标**:Lucide React
- **部署**:Vercel(免费 Hobby)
## Mock AI → 真 LLM 切换路径
目前所有 AI 能力走 `lib/ai/mock.ts` 的脚本/模板回复。接真 LLM 只需:
1. 在 `.env.local` 修改:
```
AI_PROVIDER=deepseek # 或 openai / anthropic
DEEPSEEK_API_KEY=sk-xxx
```
2. 实现 `lib/ai/real.ts`(当前为空壳,骨架可参考 `lib/ai/mock.ts`)
3. 在 `lib/ai/index.ts` 的 `selectProvider()` 加一条 switch 分支
UI、API、业务层(`lib/ai/business.ts`)**完全不改**。
## 项目结构
```
app/ 页面路由
├── page.tsx 首页
├── onboard/page.tsx AI 性格测评
├── chat/[userId]/ AI 助聊聊天
├── layout.tsx 全局布局 + PWA meta
├── error.tsx 全局错误兜底
├── not-found.tsx 404 兜底
└── globals.css 设计 token + 渐变 + 毛玻璃
components/
├── phone/ Phone / StatusBar / AppBody 外壳
├── common/ Button / StorageWarning
├── quiz/ 6 个测评专用组件 + ProfileResultCard
└── chat/ 5 个聊天专用组件(ChatHeader/MessageBubble/TypingIndicator/InputBar/AssistDrawer)
lib/
├── ai/
│ ├── types.ts AIProvider 接口 + UserProfile / ChatMessage
│ ├── index.ts 环境变量选 Provider
│ ├── mock.ts Mock 流式基础
│ ├── business.ts analyzeQuizTurn / xiaoyuReply / assistSuggest / computeCompatibility
│ ├── emotion.ts 关键词情绪检测
│ └── real.ts 预留(日后接真 LLM)
├── mock-data/
│ ├── quiz.ts 8 道测评题 + 标签
│ ├── profiles.ts 6 种性格画像 + selectProfile()
│ └── users/xiaoyu.ts 小雨人设 + 10 组触发剧本 + 7 兜底
└── store/
├── user.ts Zustand 用户画像(持久化)
└── chat.ts Zustand 聊天历史(持久化)
public/
├── icon.svg
├── icon-192.png / icon-512.png / apple-touch-icon.png / favicon-32.png
└── manifest.webmanifest PWA 清单
docs/superpowers/
├── specs/2026-04-23-matchu-mvp-design.md 产品设计文档
└── plans/2026-04-23-matchu-mvp-plan.md 20 个 task 实施计划
```
## 已交付能力
### 屏 1 · AI 性格测评 (`/onboard`)
- 8 道题,每题 4 选项,每选项携带 2–3 个性格标签
- 答题过程中 AI 流式点评(首字延迟 ~500ms + 每字 ~35ms 打字机效果)
- 进度条实时更新
- 每 3 题出现"已识别 N/10 维度"小卡片
- 答完自动从 6 种画像中挑匹配度最高的一张
- 画像持久化到 localStorage,刷新保留
### 屏 2 · AI 助聊聊天页 (`/chat/xiaoyu`)
- 小雨(22 岁 · 初入职场 · 慢热俏皮)
- 首次进入自动播放 2 条开场白
- 用户发消息 → 打字指示 → 小雨流式回复
- 关键词触发剧本:累/工作/音乐/剧/吃/睡/问候/开心/周末/宠物 共 10 组
- "AI 助聊 ✦" 按钮 → 底部抽屉 → 3 条不同语气建议(🫂 关心 / ✨ 俏皮 / 🎯 推进)
- 建议文案会根据用户性格标签做"短/长句"偏好微调
- 顶部契合度数字(60–95 区间,基于标签重合算出)
- 聊天历史持久化
## 本期明确不做(后续阶段可选)
- 注册/登录/实名认证
- 真实用户数据库(目前只有 1 位 Mock 真人"小雨")
- 真实 LLM API(目前走 Mock)
- 支付/会员
- 实时语音/视频
- 备案 / 上架 App Store / Google Play
- 自动化测试
## 后续阶段路径
```
Phase 1 ✅ (本期完成) 2 屏 + Mock AI + Vercel 演示
Phase 2 ⭕ (可选) 补齐另外 4 屏(匹配推荐 · 聊天列表 · 虚拟视频 · 我的)
Phase 3 ⭕ (可选) 接真 LLM(改 AI_PROVIDER 即可)
Phase 4 ⭕ (可选) 接真用户后端(Supabase 或 Neon)+ 登录注册
Phase 5 ⭕ (可选) 备案 + 上架 / 微信小程序版
```
## 参考资料
- `reference/matchu_app_original_mockup.html` — 原始 UI 设计稿(6 屏展示版)
- `reference/AI虚拟陪伴社交平台_项目立项书_V2_AI工具箱扩展版.docx` — 远期产品立项书
-135
View File
@@ -1,135 +0,0 @@
# MatchU VPS 部署说明
这个文件夹就是要放到 VPS 的项目包。建议上传本文件夹里面的所有内容到:
/root/matchu
## 1. 上传后进入项目目录
```bash
cd /root/matchu
```
## 2. 创建环境变量文件
```bash
cp .env.vps.example .env.local
nano .env.local
```
必须填写:
```env
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
ADMIN_PASSWORD=
ADMIN_SESSION_SECRET=
```
## 3. 初始化 Supabase 数据库
打开 Supabase 后台的 SQL Editor,执行:
```txt
supabase/schema.sql
```
这个脚本会创建后台资料、人设脚本、用户资料、帖子、点赞,以及 Storage bucket。
## 4. 安装依赖并构建
VPS 建议 Node.js 20 或更新版本。
```bash
npm ci
npm run build
```
## 5. 启动服务
临时启动:
```bash
npm run start
```
长期运行推荐 PM2:
```bash
npm install -g pm2
pm2 start npm --name matchu -- start
pm2 save
pm2 startup
```
默认端口是 3000。访问:
```txt
http://你的服务器IP:3000
```
## 6. Nginx 反代示例
```nginx
server {
listen 80;
server_name 你的域名;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## 7. 后台入口
```txt
/admin/login
```
密码使用 `.env.local` 里的 `ADMIN_PASSWORD`。
## 8. 上传存储说明
推荐使用 Supabase Storage。当前代码在配置了 Supabase 后会自动把用户头像、帖子图片上传到 Supabase Storage。
如果你想在 VPS 本地保存图片,把 `.env.local` 改成:
```env
SERVER_STORAGE_DRIVER=local
SERVER_STORAGE_ROOT=/root/matchu-storage
```
然后创建目录:
```bash
mkdir -p /root/matchu-storage/avatars /root/matchu-storage/posts
```
## 9. 常用维护命令
```bash
pm2 logs matchu
pm2 restart matchu
pm2 stop matchu
```
## 日志轮转(一次性配置)
pm2 默认把 stdout/stderr 写到 `~/.pm2/logs/` 下,不轮转。在新机器上首次部署后,跑一次:
```bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true
```
之后每个 10MB 自动切一份,保留 7 份(约 70MB 上限),老的 gzip 归档。
-1
View File
@@ -1 +0,0 @@
如果 VPS 使用本地图片存储,可参考此目录结构;生产推荐 Supabase Storage。
Binary file not shown.
Binary file not shown.
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
import type { ReactNode } from "react";
export default function TabsLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
-5
View File
@@ -1,5 +0,0 @@
import { WarmUApp } from "@/app/page";
export default function MatchPage() {
return <WarmUApp initialTab="match" showSplashOnStart={false} />;
}
-5
View File
@@ -1,5 +0,0 @@
import { WarmUApp } from "@/app/page";
export default function MePage() {
return <WarmUApp initialTab="profile" showSplashOnStart={false} />;
}
-5
View File
@@ -1,5 +0,0 @@
import { WarmUApp } from "@/app/page";
export default function MessagesPage() {
return <WarmUApp initialTab="messages" showSplashOnStart={false} />;
}
-5
View File
@@ -1,5 +0,0 @@
import { WarmUApp } from "@/app/page";
export default function ToolsPage() {
return <WarmUApp initialTab="explore" showSplashOnStart={false} />;
}
-5
View File
@@ -1,5 +0,0 @@
import { WarmUApp } from "@/app/page";
export default function VirtualPage() {
return <WarmUApp initialTab="virtual" showSplashOnStart={false} />;
}
-490
View File
@@ -1,490 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
type Ad = {
id: string
title: string
description: string | null
image_url: string
video_url: string | null
link_url: string
position: string
weight: number
is_active: boolean
start_at: string | null
end_at: string | null
impressions: number
clicks: number
created_at: string
}
const POSITION_LABELS: Record<string, string> = {
feed: '社区帖子流 · 横版',
feed_banner: '社区帖子流顶部 · 方形格',
hero: '社区顶部横幅',
side: '右下角悬浮',
home_bottom_banner: '首页底部横幅广告',
home_square_grid: '首页方形推荐广告',
}
type AdPlace = 'feed' | 'hero' | 'side' | 'home_bottom_banner' | 'home_square_grid'
type AdType = 'horizontal' | 'square'
function placeAndTypeToPosition(place: AdPlace, type: AdType): string {
if (place === 'feed') return type === 'square' ? 'feed_banner' : 'feed'
return place
}
function positionToPlaceAndType(position: string): { place: AdPlace; type: AdType } {
if (position === 'feed_banner') return { place: 'feed', type: 'square' }
if (position === 'feed') return { place: 'feed', type: 'horizontal' }
if (position === 'home_bottom_banner') return { place: 'home_bottom_banner', type: 'horizontal' }
if (position === 'home_square_grid') return { place: 'home_square_grid', type: 'square' }
return { place: position as AdPlace, type: 'horizontal' }
}
const EMPTY_FORM = {
title: '',
description: '',
image_url: '',
video_url: '',
link_url: '',
adPlace: 'feed' as AdPlace,
adType: 'horizontal' as AdType,
weight: 10,
is_active: true,
start_at: '',
end_at: '',
}
const EMPTY_BATCH = {
adType: '' as AdType | '',
adPlace: '' as AdPlace | '',
weight: '',
start_at: '',
end_at: '',
}
const inputCls = 'w-full rounded-xl border border-[#ede4dc] bg-[#f7f2ec] px-3.5 py-2.5 text-[13px] text-[#281b22] outline-none focus:border-[#9a7a84] focus:bg-white transition-colors placeholder:text-[#c0aab4]'
const labelCls = 'block text-[12px] font-bold text-[#6a5058] mb-1.5'
export default function AdsManager() {
const [ads, setAds] = useState<Ad[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState(EMPTY_FORM)
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const [deletingId, setDeletingId] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
// Batch
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchForm, setBatchForm] = useState(EMPTY_BATCH)
const [batchSaving, setBatchSaving] = useState(false)
async function load() {
setLoading(true); setError('')
try {
const res = await fetch('/api/admin/ads')
if (!res.ok) throw new Error('加载失败')
const data = await res.json()
setAds(data.ads ?? [])
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally { setLoading(false) }
}
useEffect(() => { void load() }, [])
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
function toggleAll() {
if (selected.size === ads.length) {
setSelected(new Set())
} else {
setSelected(new Set(ads.map(a => a.id)))
}
}
async function batchSave() {
if (selected.size === 0) return
setBatchSaving(true)
try {
const body: Record<string, unknown> = {}
if (batchForm.adPlace) {
const type = batchForm.adType || 'horizontal'
body.position = placeAndTypeToPosition(batchForm.adPlace, type as AdType)
} else if (batchForm.adType) {
// type alone without place — apply to each ad individually
}
if (batchForm.weight !== '') body.weight = Number(batchForm.weight)
if (batchForm.start_at) body.start_at = new Date(batchForm.start_at).toISOString()
if (batchForm.end_at) body.end_at = new Date(batchForm.end_at).toISOString()
await Promise.all([...selected].map(id => {
const perBody = { ...body }
if (batchForm.adType && !batchForm.adPlace) {
const ad = ads.find(a => a.id === id)
if (ad) {
const { place } = positionToPlaceAndType(ad.position)
perBody.position = placeAndTypeToPosition(place, batchForm.adType as AdType)
}
}
return fetch(`/api/admin/ads/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(perBody),
})
}))
setSelected(new Set())
setBatchForm(EMPTY_BATCH)
await load()
} catch { alert('批量保存失败') }
finally { setBatchSaving(false) }
}
function openCreate() {
setEditingId(null); setForm(EMPTY_FORM); setFormError(''); setShowForm(true)
}
function openEdit(ad: Ad) {
setEditingId(ad.id)
const { place, type } = positionToPlaceAndType(ad.position)
setForm({
title: ad.title, description: ad.description ?? '',
image_url: ad.image_url, video_url: ad.video_url ?? '',
link_url: ad.link_url, adPlace: place, adType: type,
weight: ad.weight, is_active: ad.is_active,
start_at: ad.start_at ? ad.start_at.slice(0, 16) : '',
end_at: ad.end_at ? ad.end_at.slice(0, 16) : '',
})
setFormError(''); setShowForm(true)
}
function closeForm() { setShowForm(false); setEditingId(null); setFormError('') }
async function save() {
if (!form.link_url.trim()) { setFormError('请填写跳转链接'); return }
setSaving(true); setFormError('')
try {
const body = {
title: form.title.trim(),
description: form.description.trim() || null,
image_url: form.image_url.trim(),
video_url: form.video_url.trim() || null,
link_url: form.link_url.trim(),
position: placeAndTypeToPosition(form.adPlace, form.adType),
weight: Number(form.weight) || 10,
is_active: form.is_active,
start_at: form.start_at ? new Date(form.start_at).toISOString() : null,
end_at: form.end_at ? new Date(form.end_at).toISOString() : null,
}
const res = editingId
? await fetch(`/api/admin/ads/${editingId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
: await fetch('/api/admin/ads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!res.ok) throw new Error(editingId ? '保存失败' : '创建失败')
closeForm(); await load()
} catch (e) { setFormError(e instanceof Error ? e.message : '保存失败') }
finally { setSaving(false) }
}
async function toggleActive(ad: Ad) {
try {
await fetch(`/api/admin/ads/${ad.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: !ad.is_active }) })
setAds(prev => prev.map(a => a.id === ad.id ? { ...a, is_active: !a.is_active } : a))
} catch {}
}
async function deleteAd(id: string) {
if (!confirm('确认删除该广告?')) return
setDeletingId(id)
try {
await fetch(`/api/admin/ads/${id}`, { method: 'DELETE' })
setAds(prev => prev.filter(a => a.id !== id))
} catch { alert('删除失败') }
finally { setDeletingId(null) }
}
const allSelected = ads.length > 0 && selected.size === ads.length
const someSelected = selected.size > 0
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="text-[13px] text-[#9a7a84]">共 {ads.length} 条广告{someSelected && <span className="ml-2 text-[#7a5060] font-semibold">· 已选 {selected.size} 条</span>}</div>
<button onClick={openCreate} className="px-4 py-2 rounded-xl bg-[#281b22] text-white text-[13px] font-bold hover:bg-[#3a2830] active:scale-95 transition-all">
+ 新建广告
</button>
</div>
{/* Batch action bar */}
{someSelected && (
<div className="mb-4 p-4 rounded-2xl border border-[#ede4dc] bg-[#fdf8f5]">
<div className="text-[12px] font-bold text-[#281b22] mb-3">批量修改选中的 {selected.size} 条广告</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className={labelCls}>广告类型</label>
<select value={batchForm.adType} onChange={e => setBatchForm(f => ({ ...f, adType: e.target.value as AdType | '' }))} className={inputCls}>
<option value="">不修改</option>
<option value="horizontal">横版</option>
<option value="square">方形</option>
</select>
</div>
<div>
<label className={labelCls}>广告位置</label>
<select value={batchForm.adPlace} onChange={e => setBatchForm(f => ({ ...f, adPlace: e.target.value as AdPlace | '' }))} className={inputCls}>
<option value="">不修改</option>
<option value="feed">社区帖子流</option>
<option value="hero">社区顶部横幅</option>
<option value="side">右下角悬浮</option>
<option value="home_bottom_banner">首页底部横幅广告</option>
<option value="home_square_grid">首页方形推荐广告</option>
</select>
</div>
<div>
<label className={labelCls}>开始时间</label>
<input type="datetime-local" value={batchForm.start_at} onChange={e => setBatchForm(f => ({ ...f, start_at: e.target.value }))}
className={inputCls.replace('px-3.5', 'px-3').replace('py-2.5 text-[13px]', 'py-2.5 text-[12px]')} />
</div>
<div>
<label className={labelCls}>结束时间</label>
<input type="datetime-local" value={batchForm.end_at} onChange={e => setBatchForm(f => ({ ...f, end_at: e.target.value }))}
className={inputCls.replace('px-3.5', 'px-3').replace('py-2.5 text-[13px]', 'py-2.5 text-[12px]')} />
</div>
<div>
<label className={labelCls}>排序(越大越靠前)</label>
<input type="number" min={1} max={999} placeholder="不修改" value={batchForm.weight}
onChange={e => setBatchForm(f => ({ ...f, weight: e.target.value }))} className={inputCls} />
</div>
</div>
<div className="flex gap-2">
<button onClick={() => { setSelected(new Set()); setBatchForm(EMPTY_BATCH) }}
className="px-4 py-2 rounded-xl border border-[#ede4dc] text-[12px] text-[#6a5058] hover:bg-[#f0e8e0] transition-colors">
取消选择
</button>
<button onClick={batchSave} disabled={batchSaving}
className="px-4 py-2 rounded-xl bg-[#281b22] text-white text-[12px] font-bold hover:bg-[#3a2830] active:scale-95 transition-all disabled:opacity-50">
{batchSaving ? '保存中...' : '批量保存'}
</button>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 px-4 py-3 rounded-xl bg-rose-50 border border-rose-200 text-rose-600 text-[13px]">
{error}<button onClick={load} className="ml-2 underline">重试</button>
</div>
)}
{loading ? (
<div className="text-center py-16 text-[#9a7a84] text-[13px]">加载中...</div>
) : ads.length === 0 ? (
<div className="text-center py-16 text-[#9a7a84] text-[13px]">暂无广告,点击右上角新建</div>
) : (
<div className="rounded-2xl border border-[#ede4dc] overflow-hidden bg-white">
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-[#ede4dc] bg-[#f7f2ec]">
<th className="px-4 py-3 w-8">
<input type="checkbox" checked={allSelected} onChange={toggleAll}
className="w-4 h-4 accent-[#281b22] cursor-pointer" />
</th>
<th className="text-left px-4 py-3 font-bold text-[#281b22]">广告</th>
<th className="text-left px-4 py-3 font-bold text-[#281b22]">位置</th>
<th className="text-left px-4 py-3 font-bold text-[#281b22]">排序</th>
<th className="text-left px-4 py-3 font-bold text-[#281b22]">效果</th>
<th className="text-left px-4 py-3 font-bold text-[#281b22]">状态</th>
<th className="text-right px-4 py-3 font-bold text-[#281b22]">操作</th>
</tr>
</thead>
<tbody>
{ads.map((ad, i) => (
<tr key={ad.id} className={`border-b border-[#ede4dc] last:border-0 ${selected.has(ad.id) ? 'bg-[#f0e8f8]' : i % 2 === 0 ? 'bg-white' : 'bg-[#fdf8f5]'} hover:bg-[#f7f0eb] transition-colors`}>
<td className="px-4 py-3">
<input type="checkbox" checked={selected.has(ad.id)} onChange={() => toggleSelect(ad.id)}
className="w-4 h-4 accent-[#281b22] cursor-pointer" />
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{ad.image_url ? (
<img src={ad.image_url} alt={ad.title} className="w-12 h-8 object-cover rounded-lg border border-[#ede4dc] flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
) : (
<div className="w-12 h-8 rounded-lg bg-[#f0e8e0] border border-[#ede4dc] flex items-center justify-center text-[#b09aa4] text-[10px] flex-shrink-0">无图</div>
)}
<div>
{ad.title && <div className="font-semibold text-[#281b22] line-clamp-1">{ad.title}</div>}
{ad.description && <div className="text-[11px] text-[#9a7a84] line-clamp-1 mt-0.5">{ad.description}</div>}
<div className="text-[10px] text-[#b09aa4] mt-0.5 line-clamp-1">{ad.link_url}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 rounded-full bg-[#f0e8e0] text-[#7a5060] text-[11px] font-semibold">
{POSITION_LABELS[ad.position] ?? ad.position}
</span>
</td>
<td className="px-4 py-3 text-[#6a5058]">{ad.weight}</td>
<td className="px-4 py-3">
<div className="text-[#6a5058]">
<span className="text-[11px]">曝光 {ad.impressions}</span>
<span className="mx-1 text-[#d4c4cc]">·</span>
<span className="text-[11px]">点击 {ad.clicks}</span>
{ad.impressions > 0 && <span className="ml-1 text-[11px] text-[#9a7a84]">({((ad.clicks / ad.impressions) * 100).toFixed(1)}%)</span>}
</div>
</td>
<td className="px-4 py-3">
<button onClick={() => toggleActive(ad)}
className={`px-2.5 py-1 rounded-full text-[11px] font-bold transition-colors ${ad.is_active ? 'bg-emerald-50 text-emerald-600 border border-emerald-200 hover:bg-emerald-100' : 'bg-[#f5ede8] text-[#9a7a84] border border-[#e8d8d0] hover:bg-[#ede4dc]'}`}>
{ad.is_active ? '投放中' : '已停止'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button onClick={() => openEdit(ad)} className="px-3 py-1.5 rounded-lg text-[12px] font-medium text-[#6a5058] hover:bg-[#f0e8e0] transition-colors mr-1">编辑</button>
<button onClick={() => deleteAd(ad.id)} disabled={deletingId === ad.id}
className="px-3 py-1.5 rounded-lg text-[12px] font-medium text-rose-500 hover:bg-rose-50 transition-colors disabled:opacity-40">
{deletingId === ad.id ? '删除中' : '删除'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={closeForm}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div className="relative w-full max-w-lg rounded-3xl bg-white shadow-2xl p-6 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h2 className="text-[17px] font-black text-[#281b22] mb-5">{editingId ? '编辑广告' : '新建广告'}</h2>
<div className="space-y-4">
<div>
<label className={labelCls}>广告标题</label>
<input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
placeholder="可选,不填则不显示标题" className={inputCls} />
</div>
<div>
<label className={labelCls}>副标题</label>
<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="可选的补充描述" className={inputCls} />
</div>
{/* Image Upload */}
<div>
<label className={labelCls}>广告图片</label>
{form.image_url ? (
<div className="relative">
<img src={form.image_url} alt="preview" className="w-full h-32 object-cover rounded-xl border border-[#ede4dc]" />
<button type="button" onClick={() => setForm(f => ({ ...f, image_url: '' }))}
className="absolute top-2 right-2 w-6 h-6 rounded-full bg-black/50 text-white text-[12px] flex items-center justify-center hover:bg-black/70">✕</button>
</div>
) : (
<label className={`flex flex-col items-center justify-center w-full h-28 rounded-xl border-2 border-dashed border-[#ede4dc] bg-[#f7f2ec] cursor-pointer hover:border-[#9a7a84] hover:bg-[#f0e8e0] transition-colors ${uploading ? 'opacity-50 pointer-events-none' : ''}`}>
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden"
onChange={async e => {
const file = e.target.files?.[0]; if (!file) return
setUploading(true)
try {
const fd = new FormData(); fd.append('file', file)
const res = await fetch('/api/admin/uploads/post-image', { method: 'POST', body: fd })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '上传失败')
setForm(f => ({ ...f, image_url: data.url }))
} catch (err) { setFormError(err instanceof Error ? err.message : '上传失败') }
finally { setUploading(false); e.target.value = '' }
}} />
{uploading ? <span className="text-[13px] text-[#9a7a84]">上传中...</span> : (
<><span className="text-2xl mb-1">🖼️</span>
<span className="text-[12px] text-[#9a7a84]">点击选择图片</span>
<span className="text-[10px] text-[#c0aab4] mt-0.5">支持 JPG、PNG、WebP、GIF,最大 10MB</span></>
)}
</label>
)}
<input value={form.image_url} onChange={e => setForm(f => ({ ...f, image_url: e.target.value }))}
placeholder="或直接粘贴图片链接" className={`${inputCls} mt-2`} />
</div>
<div>
<label className={labelCls}>跳转链接 *</label>
<input value={form.link_url} onChange={e => setForm(f => ({ ...f, link_url: e.target.value }))}
placeholder="https://example.com" className={inputCls} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>广告类型</label>
<select value={form.adType} onChange={e => setForm(f => ({ ...f, adType: e.target.value as AdType }))} className={inputCls}>
<option value="horizontal">横版</option>
<option value="square">方形</option>
</select>
</div>
<div>
<label className={labelCls}>广告位置</label>
<select value={form.adPlace} onChange={e => setForm(f => ({ ...f, adPlace: e.target.value as AdPlace }))} className={inputCls}>
<option value="feed">社区帖子流(每5条插入)</option>
<option value="hero">社区顶部横幅</option>
<option value="side">右下角悬浮</option>
<option value="home_bottom_banner">首页底部横幅广告</option>
<option value="home_square_grid">首页方形推荐广告</option>
</select>
</div>
</div>
<div>
<label className={labelCls}>排序(越大越靠前)</label>
<input type="number" value={form.weight} onChange={e => setForm(f => ({ ...f, weight: Number(e.target.value) }))}
min={1} max={999} className={inputCls} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>开始时间(可选)</label>
<input type="datetime-local" value={form.start_at} onChange={e => setForm(f => ({ ...f, start_at: e.target.value }))}
className={inputCls.replace('px-3.5', 'px-3').replace('text-[13px]', 'text-[12px]')} />
</div>
<div>
<label className={labelCls}>结束时间(可选)</label>
<input type="datetime-local" value={form.end_at} onChange={e => setForm(f => ({ ...f, end_at: e.target.value }))}
className={inputCls.replace('px-3.5', 'px-3').replace('text-[13px]', 'text-[12px]')} />
</div>
</div>
<div className="flex items-center gap-3">
<button onClick={() => setForm(f => ({ ...f, is_active: !f.is_active }))}
className={`relative rounded-full transition-colors ${form.is_active ? 'bg-emerald-500' : 'bg-[#d4c4cc]'}`}
style={{ width: 40, height: 22, minWidth: 40 }}>
<span className="absolute top-0.5 left-0.5 w-[18px] h-[18px] bg-white rounded-full shadow transition-transform"
style={{ transform: form.is_active ? 'translateX(18px)' : 'translateX(0)' }} />
</button>
<span className="text-[13px] text-[#6a5058]">{form.is_active ? '立即投放' : '暂停投放'}</span>
</div>
</div>
{formError && <div className="mt-4 px-4 py-3 rounded-xl bg-rose-50 border border-rose-200 text-rose-600 text-[12px]">{formError}</div>}
<div className="flex gap-3 mt-6">
<button onClick={closeForm} className="flex-1 py-2.5 rounded-xl border border-[#ede4dc] text-[13px] font-semibold text-[#6a5058] hover:bg-[#f7f2ec] transition-colors">取消</button>
<button onClick={save} disabled={saving} className="flex-1 py-2.5 rounded-xl bg-[#281b22] text-white text-[13px] font-bold hover:bg-[#3a2830] active:scale-95 transition-all disabled:opacity-50">
{saving ? '保存中...' : editingId ? '保存修改' : '创建广告'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
-17
View File
@@ -1,17 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { isAdminAuthenticated } from '@/lib/admin/session'
import AdsManager from './AdsManager'
export default async function AdminAdsPage() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
return (
<AdminChrome>
<div className="p-6">
<h1 className="text-xl font-black text-[#281b22] mb-1">广告管理</h1>
<p className="text-[13px] text-[#9a7a84] mb-6">管理 Hero banner、信息流、悬浮广告</p>
<AdsManager />
</div>
</AdminChrome>
)
}
-13
View File
@@ -1,13 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export function AutoRefresh({ intervalMs }: { intervalMs: number }) {
const router = useRouter()
useEffect(() => {
const timer = setInterval(() => router.refresh(), intervalMs)
return () => clearInterval(timer)
}, [router, intervalMs])
return null
}
-22
View File
@@ -1,22 +0,0 @@
'use client'
export function ClearTestButton() {
return (
<button
onClick={async () => {
if (!confirm('确认清空所有测试数据?此操作不可撤销。')) return
const res = await fetch('/api/admin/analytics-seed', { method: 'DELETE' })
const data = await res.json()
if (data.ok) {
alert(`已清空:${data.visitors} 访客 / ${data.pageViews} 页面 / ${data.events} 事件`)
location.reload()
} else {
alert('清空失败:' + (data.error ?? '未知错误'))
}
}}
className="px-3 py-1.5 rounded-lg bg-red-100 text-red-700 border border-red-300 text-xs font-semibold hover:bg-red-200 transition-colors"
>
🗑 清空测试数据
</button>
)
}
-40
View File
@@ -1,40 +0,0 @@
import { getVisitorJourney } from '@/lib/db/analytics'
export async function VisitorJourney({ visitorId }: { visitorId: string }) {
const events = getVisitorJourney(visitorId)
if (events.length === 0) {
return (
<p className="text-[11px] text-[#b09aa4] py-2">该访客暂无详细行为记录。</p>
)
}
return (
<div className="mt-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-[#a05a68] mb-2">
用户旅程(最近 {events.length} 条)
</p>
<div className="flex flex-col gap-1">
{events.map((e, i) => (
<div key={i} className="flex items-start gap-2 text-[11px]">
<span className={`mt-0.5 px-1.5 py-0.5 rounded text-[9px] font-bold shrink-0 ${
e.kind === 'page'
? 'bg-blue-100 text-blue-700'
: 'bg-pink-100 text-pink-700'
}`}>
{e.kind === 'page' ? '页面' : '事件'}
</span>
<span className="font-medium text-[#3a1f2a]">{e.name}</span>
{e.path && <span className="text-[#9a7a84] truncate max-w-[120px]">{e.path}</span>}
<span className="ml-auto text-[#b09aa4] whitespace-nowrap shrink-0">
{new Date(e.created_at).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
))}
</div>
</div>
)
}
-352
View File
@@ -1,352 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { isAdminAuthenticated } from '@/lib/admin/session'
import { getAnalyticsStats } from '@/lib/db/analytics'
import { ClearTestButton } from './ClearTestButton'
import { VisitorJourney } from './VisitorJourney'
import { AutoRefresh } from './AutoRefresh'
export const dynamic = 'force-dynamic'
export default async function AnalyticsPage({
searchParams,
}: {
searchParams: Promise<{ showTest?: string; visitor?: string }>
}) {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
const params = await searchParams
const showTest = params.showTest === '1'
const focusVisitor = params.visitor ?? null
let stats: Awaited<ReturnType<typeof getAnalyticsStats>> | null = null
let dbError = ''
try {
stats = getAnalyticsStats(showTest)
} catch (e) {
dbError = e instanceof Error ? e.message : 'SQLite error'
}
const visitorToday = stats?.visitorToday ?? 0
const visitorWeek = stats?.visitorWeek ?? 0
const visitorTotal = stats?.visitorTotal ?? 0
const pvToday = stats?.pvToday ?? 0
const pvWeek = stats?.pvWeek ?? 0
const guestToday = stats?.guestToday ?? 0
const guestTotal = stats?.guestTotal ?? 0
const registeredToday = stats?.registeredToday ?? 0
const onlineNow = stats?.onlineNow ?? 0
const topPages = stats?.topPages ?? []
const topEvents = stats?.topEvents ?? []
const deviceData = stats?.deviceData ?? []
const recentVisitors = stats?.recentVisitors ?? []
const funnel = stats?.funnel ?? []
const trend = stats?.trend ?? []
const hourly = stats?.hourly ?? []
const convRate = visitorToday > 0 ? ((registeredToday / visitorToday) * 100).toFixed(1) + '%' : '0%'
const fmt = (n: number) => n >= 10000 ? (n / 10000).toFixed(1) + 'w' : String(n)
const maxTrendUv = Math.max(...trend.map(t => t.uv), 1)
const maxHourly = Math.max(...hourly.map(h => h.count), 1)
const funnelMax = funnel[0]?.count || 1
return (
<AdminChrome>
<AutoRefresh intervalMs={30000} />
<div className="p-6 space-y-6">
{/* ── 标题 ── */}
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#a05a68] mb-1">Analytics</p>
<h1 className="font-serif text-3xl font-black">访客分析</h1>
<p className="text-xs text-[#9a7a84] mt-1">游客 · 访问 · 转化 · {new Date().toLocaleString('zh-CN')}</p>
</div>
{/* 在线人数徽章 */}
<div className="flex items-center gap-2 bg-green-50 border border-green-200 rounded-xl px-4 py-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-bold text-green-700">{onlineNow} 人在线</span>
<span className="text-[10px] text-green-600">(5 分钟内)</span>
</div>
</div>
{/* ── Status banner ── */}
{dbError ? (
<div className="rounded-xl border border-red-300 bg-red-50 p-4 text-sm font-semibold text-red-900">
⚠️ SQLite 错误:{dbError}
</div>
) : (
<div className="rounded-xl border border-green-300 bg-green-50 p-3 flex flex-wrap items-center justify-between gap-2 text-sm text-green-800">
<span className="flex items-center gap-2">
<span className="text-base">✅</span>
<span className="font-semibold">
SQLite 本地数据库就绪 · 当前:
<span className={showTest ? 'text-orange-600 ml-1' : 'text-green-700 ml-1'}>
{showTest ? '真实 + 测试数据' : '仅真实数据'}
</span>
</span>
</span>
<div className="flex items-center gap-2 flex-wrap">
<a
href={showTest ? '/admin/analytics' : '/admin/analytics?showTest=1'}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
showTest
? 'bg-orange-100 text-orange-700 border border-orange-300 hover:bg-orange-200'
: 'bg-gray-100 text-gray-600 border border-gray-300 hover:bg-gray-200'
}`}
>
{showTest ? '👁 正在看测试数据' : '👁 查看含测试数据'}
</a>
<ClearTestButton />
<form action="/api/admin/analytics-seed" method="POST">
<button type="submit" className="px-3 py-1.5 rounded-lg bg-green-700 text-white text-xs font-semibold hover:bg-green-800 transition-colors">
注入500条测试数据
</button>
</form>
</div>
</div>
)}
{/* ── KPI 卡片 ── */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<MCard label="今日访客 UV" val={fmt(visitorToday)} sub={`总 ${fmt(visitorTotal)}`} color="blue" />
<MCard label="今日 PV" val={fmt(pvToday)} sub={`7日 ${fmt(pvWeek)}`} color="green" />
<MCard label="今日游客" val={fmt(guestToday)} sub={`总游客 ${fmt(guestTotal)}`} color="orange" />
<MCard label="注册转化率" val={convRate} sub={`今日注册 ${registeredToday}`} color="purple" />
<MCard label="7 日访客" val={fmt(visitorWeek)} sub={`今日在线 ${onlineNow}`} color="pink" />
</div>
{/* ── 30 天 UV 趋势图 ── */}
<section className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-4">📈 30 天 UV 趋势</h2>
{trend.length === 0 ? (
<p className="text-[12px] text-[#b09aa4] py-4 text-center">暂无趋势数据</p>
) : (
<div className="overflow-x-auto">
<div className="flex items-end gap-[3px] min-w-[600px] h-28">
{trend.map((t) => (
<div key={t.date} className="flex-1 flex flex-col items-center gap-0.5 group" title={`${t.date}\nUV: ${t.uv}\nPV: ${t.pv}`}>
<div
className="w-full rounded-t bg-gradient-to-t from-[#e8607a] to-[#a970ff] opacity-80 group-hover:opacity-100 transition-opacity"
style={{ height: `${Math.max(2, (t.uv / maxTrendUv) * 100)}%` }}
/>
{t.date.slice(5) === trend[0].date.slice(5) || t.date.slice(8) === '01' || t.date === trend[0].date || t.date === trend[trend.length - 1].date ? (
<span className="text-[8px] text-[#b09aa4] whitespace-nowrap">{t.date.slice(5)}</span>
) : null}
</div>
))}
</div>
<div className="flex gap-4 mt-3 text-[11px] text-[#9a7a84]">
<span className="flex items-center gap-1">
<span className="w-3 h-2 rounded bg-gradient-to-r from-[#e8607a] to-[#a970ff] inline-block" />
UV(独立访客)
</span>
<span>峰值 {maxTrendUv} · 共 {trend.reduce((s, t) => s + t.uv, 0)} 次访问</span>
</div>
</div>
)}
</section>
{/* ── 今日 24 小时分布 + 设备分布 ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<section className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-4">⏱ 今日 24 小时分布</h2>
{hourly.every(h => h.count === 0) ? (
<p className="text-[12px] text-[#b09aa4] py-4 text-center">今日暂无数据</p>
) : (
<div className="flex items-end gap-[2px] h-20">
{hourly.map((h) => (
<div key={h.hour} className="flex-1 flex flex-col items-center gap-0.5 group" title={`${h.hour}:00 — ${h.count} PV`}>
<div
className="w-full rounded-t bg-gradient-to-t from-[#3b82f6] to-[#a970ff] opacity-70 group-hover:opacity-100 transition-opacity"
style={{ height: `${Math.max(2, (h.count / maxHourly) * 100)}%` }}
/>
{h.hour % 6 === 0 ? (
<span className="text-[8px] text-[#b09aa4]">{h.hour}</span>
) : null}
</div>
))}
</div>
)}
</section>
<section className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-3">📱 设备分布</h2>
{deviceData.length > 0 ? deviceData.map(d => (
<div key={d.label} className="flex items-center gap-2 py-1.5 border-b border-[#f5ede8] last:border-0">
<span className="text-[12px] text-[#6a5058] w-12">{d.label}</span>
<div className="flex-1 bg-[#ede4dc] rounded-full h-3 overflow-hidden">
<div className="h-full rounded-full bg-gradient-to-r from-[#3b82f6] to-[#a970ff]"
style={{ width: d.pct }} />
</div>
<span className="text-[11px] font-semibold text-[#281b22] w-12 text-right">{d.pct}</span>
<span className="text-[10px] text-[#9a7a84] w-10 text-right">{fmt(d.count)}</span>
</div>
)) : <p className="text-[12px] text-[#b09aa4]">暂无数据</p>}
</section>
</div>
{/* ── 转化漏斗 ── */}
<section className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-4">🎯 用户转化漏斗(全量历史)</h2>
{funnel.every(f => f.count === 0) ? (
<p className="text-[12px] text-[#b09aa4] py-4 text-center">暂无漏斗数据。用户完成注册/发消息/支付后将自动出现。</p>
) : (
<div className="space-y-2">
{funnel.map((step, i) => {
const pct = funnelMax > 0 ? (step.count / funnelMax) * 100 : 0
const dropRate = i > 0 && funnel[i - 1].count > 0
? (100 - (step.count / funnel[i - 1].count) * 100).toFixed(0)
: null
return (
<div key={step.name}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-[10px] text-[#9a7a84] w-4">{i + 1}</span>
<span className="text-[12px] font-medium text-[#3a1f2a]">{step.name}</span>
{dropRate && Number(dropRate) > 0 && (
<span className="text-[10px] text-red-400 font-medium">↓ -{dropRate}%</span>
)}
</div>
<span className="text-[13px] font-bold text-[#281b22]">{fmt(step.count)}</span>
</div>
<div className="bg-[#f5ede8] rounded-full h-4 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-[#e8607a] to-[#a970ff] transition-all"
style={{ width: `${Math.max(pct, step.count > 0 ? 1 : 0)}%` }}
/>
</div>
</div>
)
})}
</div>
)}
</section>
{/* ── 页面 Top10 + 事件 Top12 ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<Section title="📄 今日页面访问 Top 10">
{topPages.length > 0 ? topPages.map((p, i) => (
<div key={p.path} className="flex items-center gap-2 py-1 border-b border-[#f5ede8] last:border-0">
<span className="text-[10px] text-[#b09aa4] w-5">{i + 1}</span>
<span className="flex-1 text-[11px] text-[#6a5058] truncate">{p.path}</span>
<span className="text-[12px] font-bold text-[#281b22]">{p.count}</span>
</div>
)) : <p className="text-[12px] text-[#b09aa4]">今日暂无页面访问</p>}
</Section>
<Section title="⚡ 今日行为事件 Top 12">
{topEvents.length > 0 ? topEvents.map(ev => (
<div key={ev.name} className="flex items-center gap-2 py-1">
<span className="text-[11px] text-[#6a5058] w-32 truncate">{ev.name}</span>
<div className="flex-1 bg-[#ede4dc] rounded-full h-2 overflow-hidden">
<div className="h-full rounded-full bg-gradient-to-r from-[#e8607a] to-[#a970ff]"
style={{ width: `${Math.min(100, (ev.count / (topEvents[0]?.count || 1)) * 100)}%` }} />
</div>
<span className="text-[11px] font-bold text-[#281b22] w-8 text-right">{ev.count}</span>
</div>
)) : <p className="text-[12px] text-[#b09aa4]">今日暂无事件</p>}
</Section>
</div>
{/* ── 最近访客(含旅程展开) ── */}
<div className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-4">🕐 最近 20 位访客</h2>
{recentVisitors.length === 0 ? (
<p className="text-[12px] text-[#b09aa4] py-4 text-center">暂无访客记录。打开前台 APP 后刷新此页面,或点击上方注入测试数据。</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12px]">
<thead>
<tr className="border-b border-[#ede4dc]">
{['', 'Visitor ID', '状态', '次数', '设备', '浏览器', 'OS', '入口页', '来源', '最后活跃', '首次'].map(h => (
<th key={h} className="text-left py-2 px-2 text-[#9a7a84] font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{recentVisitors.map(v => (
<>
<tr key={v.visitor_id} className="border-b border-[#f5ede8] hover:bg-[#fdf8f5]">
<td className="py-2 px-2">
<a href={`/admin/analytics?${showTest ? 'showTest=1&' : ''}visitor=${v.visitor_id}`}
className="text-[10px] text-[#a05a68] hover:text-[#e8607a] font-medium whitespace-nowrap">
{focusVisitor === v.visitor_id ? '▾ 收起' : '▸ 旅程'}
</a>
</td>
<td className="py-2 px-2 font-mono text-[10px] text-[#9a7a84]">{v.visitor_id.slice(0, 8)}…</td>
<td className="py-2 px-2">
{v.user_id ? (
<span className="text-green-600 font-semibold">已注册</span>
) : (
<span className="text-orange-500">游客</span>
)}
</td>
<td className="py-2 px-2 text-center text-[#6a5058]">{v.entry_count ?? 1}</td>
<td className="py-2 px-2 text-[#6a5058]">{v.device_type ?? '—'}</td>
<td className="py-2 px-2 text-[#6a5058]">{v.browser ?? '—'}</td>
<td className="py-2 px-2 text-[#6a5058]">{v.os ?? '—'}</td>
<td className="py-2 px-2 text-[#6a5058] max-w-[100px] truncate">{v.landing_page ?? '/'}</td>
<td className="py-2 px-2 text-[#9a7a84] max-w-[100px] truncate" title={v.referrer ?? ''}>
{v.utm_source ? (
<span className="text-[10px] font-semibold text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{v.utm_source}</span>
) : v.referrer ? (
<span className="text-[10px] text-[#9a7a84]">{(() => { try { return new URL(v.referrer).hostname } catch { return v.referrer.slice(0, 20) } })()}</span>
) : (
<span className="text-[10px] text-[#c0b0b5]">直接访问</span>
)}
</td>
<td className="py-2 px-2 text-[#9a7a84] whitespace-nowrap">
{new Date(v.last_seen_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="py-2 px-2 text-[#9a7a84] whitespace-nowrap">
{new Date(v.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</td>
</tr>
{focusVisitor === v.visitor_id && (
<tr key={`journey-${v.visitor_id}`}>
<td colSpan={11} className="px-4 pb-4 bg-[#fdf8f5]">
<VisitorJourney visitorId={v.visitor_id} />
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</AdminChrome>
)
}
function MCard({ label, val, sub, color }: { label: string; val: string; sub: string; color: string }) {
const cls = ({
blue: 'from-blue-50 to-blue-100 border-blue-200 text-blue-700',
green: 'from-green-50 to-green-100 border-green-200 text-green-700',
orange: 'from-orange-50 to-orange-100 border-orange-200 text-orange-700',
purple: 'from-purple-50 to-purple-100 border-purple-200 text-purple-700',
pink: 'from-pink-50 to-pink-100 border-pink-200 text-pink-700',
} as Record<string, string>)[color] ?? ''
return (
<div className={`rounded-2xl p-4 bg-gradient-to-br border ${cls}`}>
<div className="text-[11px] font-medium mb-1">{label}</div>
<div className="text-3xl font-bold">{val}</div>
<div className="text-[10px] mt-1 opacity-80">{sub}</div>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-3">{title}</h2>
<div>{children}</div>
</section>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { AnnouncementManager } from '@/components/admin/AnnouncementManager'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminAnnouncementsPage() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
return (
<AdminChrome>
<AnnouncementManager />
</AdminChrome>
)
}
-5
View File
@@ -1,5 +0,0 @@
import type { ReactNode } from 'react'
export default function AdminLayout({ children }: { children: ReactNode }) {
return children
}
-18
View File
@@ -1,18 +0,0 @@
import { redirect } from 'next/navigation'
import { LoginForm } from '@/components/admin/LoginForm'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminLoginPage() {
if (await isAdminAuthenticated()) {
redirect('/admin')
}
return (
<div className="flex min-h-screen items-center justify-center bg-[#14070d] px-4">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_10%,rgba(255,143,107,0.25),transparent_35%),radial-gradient(circle_at_80%_80%,rgba(185,95,140,0.22),transparent_35%)]" />
<div className="relative z-10">
<LoginForm />
</div>
</div>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { AdminChrome } from '@/components/admin/AdminChrome'
import { isAdminAuthenticated } from '@/lib/admin/session'
import { redirect } from 'next/navigation'
import { LogsViewer } from '@/components/admin/LogsViewer'
export default async function Page() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
return (
<AdminChrome>
<LogsViewer />
</AdminChrome>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { MembershipManager } from '@/components/admin/MembershipManager'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminMembershipPage() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
return (
<AdminChrome>
<MembershipManager />
</AdminChrome>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { ModerationManager } from '@/components/admin/ModerationManager'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminModerationPage() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
return (
<AdminChrome>
<ModerationManager />
</AdminChrome>
)
}
-96
View File
@@ -1,96 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
type Order = {
id: string; user_id: string; plan: string; amount_fen: number
pay_method: string; out_trade_no: string; created_at: string
}
const PLAN_LABEL: Record<string, string> = { month: '月卡', quarter: '季卡', year: '年卡' }
const METHOD_LABEL: Record<string, string> = { wechat: '微信', alipay: '支付宝' }
export default function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [activating, setActivating] = useState<string | null>(null)
const [done, setDone] = useState<Set<string>>(new Set())
async function load() {
setLoading(true)
try {
const res = await fetch('/api/admin/orders')
const data = await res.json() as { orders: Order[] }
setOrders(data.orders ?? [])
} finally {
setLoading(false)
}
}
useEffect(() => { void load() }, [])
async function activate(order_id: string) {
setActivating(order_id)
try {
const res = await fetch('/api/admin/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id }),
})
if (res.ok) setDone(prev => new Set([...prev, order_id]))
} finally {
setActivating(null)
}
}
return (
<div className="max-w-3xl mx-auto py-10 px-4">
<div className="mb-6 flex items-center justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-[#a05a68] mb-1">Payment</p>
<h1 className="font-serif text-3xl font-black">待确认订单</h1>
<p className="text-sm text-[#6a5058] mt-1">用户已付款通知,点击激活开通会员</p>
</div>
<button onClick={load} className="px-4 py-2 rounded-xl border border-[#dfd1c6] text-sm font-bold text-[#7c626a] hover:bg-[#fdf8f5]">
刷新
</button>
</div>
{loading ? (
<p className="text-sm text-[#a05a68]">加载中…</p>
) : orders.length === 0 ? (
<div className="rounded-2xl border border-[#dfd1c6] bg-white p-8 text-center text-sm text-[#a05a68]">
暂无待确认订单
</div>
) : (
<div className="space-y-3">
{orders.map((o) => (
<div key={o.id} className="rounded-2xl border border-[#dfd1c6] bg-white p-5 flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-sm">{PLAN_LABEL[o.plan] ?? o.plan}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-[#f5ede8] text-[#a05a68] font-semibold">
{METHOD_LABEL[o.pay_method] ?? o.pay_method}
</span>
<span className="font-bold text-[#a05a68]">¥{(o.amount_fen / 100).toFixed(0)}</span>
</div>
<p className="text-xs text-[#9a8080] truncate">{o.out_trade_no}</p>
<p className="text-xs text-[#bbb] mt-0.5">{new Date(o.created_at).toLocaleString('zh-CN')}</p>
</div>
{done.has(o.id) ? (
<span className="px-4 py-2 rounded-xl bg-green-50 text-green-600 font-bold text-sm">✅ 已激活</span>
) : (
<button
onClick={() => activate(o.id)}
disabled={activating === o.id}
className="px-4 py-2 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-bold text-sm shadow disabled:opacity-50 whitespace-nowrap"
>
{activating === o.id ? '激活中…' : '激活会员'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
-293
View File
@@ -1,293 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { isAdminAuthenticated } from '@/lib/admin/session'
import { isSupabaseConfigured } from '@/lib/supabase/rest'
import { fetchAdminStats } from '@/lib/backend/admin-stats'
export const dynamic = 'force-dynamic'
export default async function AdminHomePage() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
const configured = isSupabaseConfigured()
const stats = configured ? await fetchAdminStats() : null
const yuan = (fen: number) => '¥' + (fen / 100).toFixed(0)
const fmt = (n: number) => n >= 10000 ? (n / 10000).toFixed(1) + 'w' : String(n)
const pct = (n: number) => n.toFixed(1) + '%'
return (
<AdminChrome>
<div className="p-6 space-y-6">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#a05a68] mb-1">Operations Dashboard</p>
<h1 className="font-serif text-3xl font-black">运营数据概览</h1>
<p className="text-xs text-[#9a7a84] mt-1">实时数据 · {new Date().toLocaleString('zh-CN')}</p>
</div>
{!configured && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm font-semibold text-amber-900">
Supabase 尚未配置,显示模拟数据。请设置 SUPABASE_URL 和 SUPABASE_SERVICE_ROLE_KEY。
</div>
)}
{/* ── 顶部 8 核心指标 ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<TopCard label="今日访客 UV" value={fmt(stats?.analytics.todayUV ?? 0)} sub={`总访客 ${fmt(stats?.analytics.totalVisitors ?? 0)}`} color="blue" />
<TopCard label="今日注册用户" value={fmt(stats?.users.today ?? 0)} sub={`总 ${fmt(stats?.users.total ?? 0)}`} color="green" />
<TopCard label="注册转化率" value={pct(stats?.analytics.conversionRate ?? 0)} sub={`游客 ${fmt(stats?.analytics.todayGuests ?? 0)} 人`} color="purple" />
<TopCard label="今日收入" value={yuan(stats?.orders.todayRevenueFen ?? 0)} sub={`待确认 ${fmt(stats?.orders.pendingCount ?? 0)} 笔`} color="amber" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{/* ── 访问数据 ── */}
<StatSection title="🌐 访问数据">
<StatRow label="今日访客 UV" value={fmt(stats?.analytics.todayUV ?? 0)} tone="blue" />
<StatRow label="今日页面 PV" value={fmt(stats?.analytics.todayPV ?? 0)} />
<StatRow label="今日未注册游客" value={fmt(stats?.analytics.todayGuests ?? 0)} tone="orange" />
<StatRow label="今日注册用户" value={fmt(stats?.users.today ?? 0)} tone="green" />
<StatRow label="注册转化率" value={pct(stats?.analytics.conversionRate ?? 0)} tone="purple" />
</StatSection>
{/* ── 用户活跃 ── */}
<StatSection title="👥 用户活跃">
<StatRow label="总注册用户" value={fmt(stats?.users.total ?? 0)} />
<StatRow label="今日新增" value={fmt(stats?.users.today ?? 0)} tone="green" />
<StatRow label="今日 DAU" value={fmt(stats?.analytics.todayDau ?? 0)} tone="blue" />
<StatRow label="7日 WAU" value={fmt(stats?.analytics.weekWau ?? 0)} tone="orange" />
<StatRow label="30日 MAU" value={fmt(stats?.analytics.monthMau ?? 0)} tone="purple" />
<StatRow label="7日新增" value={fmt(stats?.users.week ?? 0)} />
<StatRow label="30日新增" value={fmt(stats?.users.month ?? 0)} />
</StatSection>
{/* ── 行为数据 ── */}
<StatSection title="💫 行为数据">
<StatRow label="今日消息数" value={fmt(stats?.analytics.todayMessages ?? 0)} />
<StatRow label="今日喜欢/滑动" value={fmt(stats?.analytics.todaySwipes ?? 0)} tone="orange" />
<StatRow label="今日仅喜欢" value={fmt(stats?.analytics.todayLikes ?? 0)} tone="green" />
<StatRow label="今日新好友" value={fmt(stats?.analytics.todayFriends ?? 0)} tone="purple" />
<StatRow label="今日新帖子" value={fmt(stats?.posts.today ?? 0)} />
<StatRow label="今日事件总数" value={fmt(stats?.analytics.todayEvents ?? 0)} />
</StatSection>
{/* ── 订单数据 ── */}
<StatSection title="💰 订单数据">
<StatRow label="累计收入" value={yuan(stats?.orders.revenueFen ?? 0)} tone="amber" />
<StatRow label="今日收入" value={yuan(stats?.orders.todayRevenueFen ?? 0)} tone="green" />
<StatRow label="7日收入" value={yuan(stats?.orders.weekRevenueFen ?? 0)} tone="orange" />
<StatRow label="30日收入" value={yuan(stats?.orders.monthRevenueFen ?? 0)} tone="purple" />
<StatRow label="累计已付订单" value={fmt(stats?.orders.totalPaid ?? 0)} />
<StatRow label="待确认订单" value={fmt(stats?.orders.pendingCount ?? 0)} tone="red" />
</StatSection>
{/* ── 内容数据 ── */}
<StatSection title="📋 内容数据">
<StatRow label="匹配资料" value={`${stats?.profiles.published ?? 0}/${stats?.profiles.total ?? 0}`} />
<StatRow label="聊天人设" value={`${stats?.personas.published ?? 0}/${stats?.personas.total ?? 0}`} />
<StatRow label="聊天脚本启用" value={`${stats?.scripts.enabled ?? 0}/${stats?.scripts.total ?? 0}`} />
<StatRow label="社区帖子" value={fmt(stats?.posts.total ?? 0)} />
<StatRow label="已下架帖子" value={fmt(stats?.posts.hidden ?? 0)} tone="red" />
</StatSection>
{/* ── 今日热门事件 ── */}
<StatSection title="📊 今日事件 Top 8">
{stats?.analytics.topEvents.length ? (
<div className="w-full space-y-2">
{stats.analytics.topEvents.map(ev => (
<div key={ev.name} className="flex items-center gap-2">
<span className="text-[11px] text-[#6a5058] w-36 truncate">{ev.name}</span>
<div className="flex-1 bg-[#ede4dc] rounded-full h-2 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-[#e8607a] to-[#a970ff]"
style={{ width: `${Math.min(100, (ev.count / (stats.analytics.topEvents[0]?.count || 1)) * 100)}%` }}
/>
</div>
<span className="text-[11px] font-semibold text-[#281b22] w-8 text-right">{ev.count}</span>
</div>
))}
</div>
) : (
<p className="text-[12px] text-[#b09aa4]">暂无数据(需先运行 analytics-schema.sql)</p>
)}
</StatSection>
</div>
{/* ── 转化漏斗 ── */}
{stats && (
<div className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-lg font-bold text-[#281b22] mb-4">🔻 用户转化漏斗(全量)</h2>
<div className="flex flex-col gap-2">
{stats.analytics.funnelSteps.map((step, i) => {
const prev = i === 0 ? step.count : stats.analytics.funnelSteps[i - 1].count
const rate = prev > 0 ? Math.round((step.count / prev) * 100) : 0
const overall = stats.analytics.funnelSteps[0].count > 0
? Math.round((step.count / stats.analytics.funnelSteps[0].count) * 100)
: 0
return (
<div key={step.name} className="flex items-center gap-3">
<span className="text-[12px] text-[#6a5058] w-20 shrink-0">{step.name}</span>
<div className="flex-1 bg-[#f5ede8] rounded-full h-6 overflow-hidden relative">
<div
className="h-full rounded-full transition-all"
style={{
width: `${overall}%`,
background: `linear-gradient(to right, #e8607a, #a970ff)`,
opacity: 1 - i * 0.08,
}}
/>
<span className="absolute inset-0 flex items-center px-3 text-[11px] font-bold text-[#281b22]">
{fmt(step.count)} 人
</span>
</div>
<span className="text-[11px] text-[#9a7a84] w-16 text-right shrink-0">
{i === 0 ? '起点' : `上步 ${rate}%`}
</span>
</div>
)
})}
</div>
</div>
)}
{/* ── 30日趋势 ── */}
{stats && (
<div className="bg-white rounded-2xl border border-[#ede4dc] p-5 space-y-6">
<h2 className="font-serif text-lg font-bold text-[#281b22]">📈 30日趋势</h2>
<ChartLine
title="访客数(Visitor Sessions)"
data={stats.trend.days.map((x, i) => ({ x, y: stats.trend.visitors[i] }))}
color="#3b82f6"
/>
<ChartLine
title="新增注册用户"
data={stats.trend.days.map((x, i) => ({ x, y: stats.trend.users[i] }))}
color="#22c55e"
/>
<ChartLine
title="订单收入(元)"
data={stats.trend.days.map((x, i) => ({ x, y: stats.trend.revenueFen[i] / 100 }))}
color="#f97316"
yFormat={(v) => '¥' + Math.round(v)}
/>
</div>
)}
</div>
</AdminChrome>
)
}
function TopCard({ label, value, sub, color }: {
label: string; value: string; sub: string
color: 'green' | 'orange' | 'purple' | 'amber' | 'blue' | 'red'
}) {
const cls = {
green: 'from-green-50 to-green-100 border-green-200 text-green-700',
orange: 'from-orange-50 to-orange-100 border-orange-200 text-orange-700',
purple: 'from-purple-50 to-purple-100 border-purple-200 text-purple-700',
amber: 'from-amber-50 to-amber-100 border-amber-200 text-amber-700',
blue: 'from-blue-50 to-blue-100 border-blue-200 text-blue-700',
red: 'from-red-50 to-red-100 border-red-200 text-red-700',
}[color]
return (
<div className={`rounded-2xl p-4 bg-gradient-to-br border ${cls}`}>
<div className="text-[11px] font-medium mb-1">{label}</div>
<div className="text-3xl font-bold">{value}</div>
<div className="text-[10px] mt-1 opacity-80">{sub}</div>
</div>
)
}
function StatSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-3">{title}</h2>
<div className="space-y-2">{children}</div>
</section>
)
}
function StatRow({ label, value, tone }: {
label: string; value: string
tone?: 'green' | 'orange' | 'purple' | 'amber' | 'red' | 'blue'
}) {
const color = tone ? {
green: 'text-green-600',
orange: 'text-orange-600',
purple: 'text-purple-600',
amber: 'text-amber-600',
red: 'text-red-600',
blue: 'text-blue-600',
}[tone] : 'text-[#281b22]'
return (
<div className="flex items-center justify-between py-1 border-b border-[#f5ede8] last:border-0">
<span className="text-[12px] text-[#6a5058]">{label}</span>
<span className={`text-[13px] font-bold ${color}`}>{value}</span>
</div>
)
}
function ChartLine({ title, data, color, yFormat }: {
title: string
data: { x: string; y: number }[]
color: string
yFormat?: (v: number) => string
}) {
const max = Math.max(...data.map(d => d.y), 1)
const fmt = yFormat ?? ((v) => Math.round(v).toString())
return (
<div>
<div className="text-[12px] font-semibold text-[#6a5058] mb-2">{title}</div>
<svg viewBox="0 0 900 160" className="w-full" preserveAspectRatio="none">
{[0, 0.5, 1].map(t => {
const y = 140 - 120 * t
return (
<g key={t}>
<line x1={50} x2={880} y1={y} y2={y} stroke="#ede4dc" strokeWidth={1} strokeDasharray="3,3" />
<text x={45} y={y + 4} textAnchor="end" fontSize={9} fill="#b09aa4" fontFamily="monospace">{fmt(max * t)}</text>
</g>
)
})}
{data.length > 1 && (
<>
<polygon
fill={color + '20'}
points={[
...data.map((d, i) => {
const x = 50 + (830 / (data.length - 1)) * i
const y = 140 - (d.y / max) * 120
return `${x},${y}`
}),
`${50 + 830},140`, `50,140`,
].join(' ')}
/>
<polyline
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
points={data.map((d, i) => {
const x = 50 + (830 / (data.length - 1)) * i
const y = 140 - (d.y / max) * 120
return `${x},${y}`
}).join(' ')}
/>
</>
)}
{data.map((d, i) => {
const x = 50 + (830 / Math.max(data.length - 1, 1)) * i
const y = 140 - (d.y / max) * 120
return (
<g key={i}>
<circle cx={x} cy={y} r={2} fill={color} />
{(i % 5 === 0 || i === data.length - 1) && (
<text x={x} y={155} textAnchor="middle" fontSize={8} fill="#b09aa4">{d.x}</text>
)}
</g>
)
})}
</svg>
</div>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { PaymentConfigForm } from '@/components/admin/PaymentConfigForm'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminPaymentPage() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
return (
<AdminChrome>
<PaymentConfigForm />
</AdminChrome>
)
}
-16
View File
@@ -1,16 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { PersonaManager } from '@/components/admin/PersonaManager'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminPersonasPage() {
if (!(await isAdminAuthenticated())) {
redirect('/admin/login')
}
return (
<AdminChrome>
<PersonaManager />
</AdminChrome>
)
}
-16
View File
@@ -1,16 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { PostManager } from '@/components/admin/PostManager'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminPostsPage() {
if (!(await isAdminAuthenticated())) {
redirect('/admin/login')
}
return (
<AdminChrome>
<PostManager />
</AdminChrome>
)
}
-16
View File
@@ -1,16 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { ProfileManager } from '@/components/admin/ProfileManager'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminProfilesPage() {
if (!(await isAdminAuthenticated())) {
redirect('/admin/login')
}
return (
<AdminChrome>
<ProfileManager />
</AdminChrome>
)
}
-16
View File
@@ -1,16 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { ScriptManager } from '@/components/admin/ScriptManager'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminScriptsPage() {
if (!(await isAdminAuthenticated())) {
redirect('/admin/login')
}
return (
<AdminChrome>
<ScriptManager />
</AdminChrome>
)
}
-190
View File
@@ -1,190 +0,0 @@
'use client'
import { useState } from 'react'
const TABLE_LABELS: Record<string, string> = {
visitor_sessions: '游客会话',
page_views: '页面访问',
user_events: '行为事件',
feedback: '用户反馈',
system_logs: '系统日志',
error_logs: '错误日志',
}
export default function SetupClient({
tableStatus,
sqlContent,
projectRef,
configured,
}: {
tableStatus: Record<string, boolean>
sqlContent: string
projectRef: string
configured: boolean
}) {
const [copied, setCopied] = useState(false)
const allReady = Object.values(tableStatus).every(Boolean)
const supabaseEditorUrl = projectRef
? `https://supabase.com/dashboard/project/${projectRef}/sql/new`
: 'https://supabase.com/dashboard'
function handleCopy() {
// navigator.clipboard requires HTTPS — fallback for HTTP
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(sqlContent).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2500)
}).catch(() => fallbackCopy())
} else {
fallbackCopy()
}
} catch { fallbackCopy() }
}
function fallbackCopy() {
const ta = document.createElement('textarea')
ta.value = sqlContent
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'
document.body.appendChild(ta)
ta.focus()
ta.select()
try {
document.execCommand('copy')
setCopied(true)
setTimeout(() => setCopied(false), 2500)
} catch {}
document.body.removeChild(ta)
}
return (
<div className="p-6 space-y-6 max-w-3xl">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#a05a68] mb-1">Setup</p>
<h1 className="font-serif text-3xl font-black">数据分析初始化</h1>
<p className="text-xs text-[#9a7a84] mt-1">建表 · 验证 · 一键完成</p>
</div>
{!configured && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm font-semibold text-amber-900">
⚠️ Supabase 未配置。请设置 <code>SUPABASE_URL</code> 和 <code>SUPABASE_SERVICE_ROLE_KEY</code>。
</div>
)}
{/* ── 表状态 ── */}
<div className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-4">📋 数据表状态</h2>
{!configured ? (
<p className="text-sm text-[#9a7a84]">Supabase 未配置,无法检查表状态。</p>
) : (
<div className="space-y-2">
{Object.entries(TABLE_LABELS).map(([key, label]) => {
const exists = tableStatus[key]
return (
<div key={key} className="flex items-center justify-between py-2 border-b border-[#f5ede8] last:border-0">
<div>
<span className="text-[13px] font-medium text-[#281b22]">{label}</span>
<span className="ml-2 text-[11px] text-[#b09aa4] font-mono">{key}</span>
</div>
{exists === undefined ? (
<span className="text-[12px] text-[#9a7a84]">检查中…</span>
) : exists ? (
<span className="text-[12px] font-semibold text-green-600 bg-green-50 border border-green-200 rounded-lg px-3 py-1">✓ 已创建</span>
) : (
<span className="text-[12px] font-semibold text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-1">✗ 未创建</span>
)}
</div>
)
})}
</div>
)}
{allReady && configured && (
<div className="mt-4 rounded-xl bg-green-50 border border-green-200 p-3 text-sm font-semibold text-green-800">
🎉 所有表已就绪!访客数据将自动记录。
</div>
)}
</div>
{/* ── 建表步骤 ── */}
{configured && !allReady && (
<div className="bg-white rounded-2xl border border-[#ede4dc] p-5 space-y-4">
<h2 className="font-serif text-[15px] font-bold text-[#281b22]">🔧 一键建表步骤</h2>
<ol className="space-y-3 text-[13px] text-[#6a5058]">
<li className="flex gap-3">
<span className="w-6 h-6 rounded-full bg-[#e8607a] text-white text-[11px] font-bold flex items-center justify-center shrink-0 mt-0.5">1</span>
<span>点击下方"复制 SQL"按钮,复制建表语句</span>
</li>
<li className="flex gap-3">
<span className="w-6 h-6 rounded-full bg-[#e8607a] text-white text-[11px] font-bold flex items-center justify-center shrink-0 mt-0.5">2</span>
<span>
点击"打开 Supabase SQL Editor",进入 Supabase 控制台
</span>
</li>
<li className="flex gap-3">
<span className="w-6 h-6 rounded-full bg-[#e8607a] text-white text-[11px] font-bold flex items-center justify-center shrink-0 mt-0.5">3</span>
<span>粘贴 SQL 内容,点击"Run"执行</span>
</li>
<li className="flex gap-3">
<span className="w-6 h-6 rounded-full bg-[#22c55e] text-white text-[11px] font-bold flex items-center justify-center shrink-0 mt-0.5">4</span>
<span>回到此页面刷新,确认所有表变为"✓ 已创建"</span>
</li>
</ol>
<div className="flex flex-wrap gap-3 pt-2">
<button
onClick={handleCopy}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-semibold text-sm transition-colors"
style={{ background: copied ? '#22c55e' : '#281b22', color: '#fff' }}
>
{copied ? '✓ 已复制!' : '复制 SQL'}
</button>
<a
href={supabaseEditorUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-semibold text-sm bg-[#3ecf8e] text-white hover:bg-[#2db877] transition-colors"
>
打开 Supabase SQL Editor →
</a>
<button
onClick={() => window.location.reload()}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-semibold text-sm bg-[#f5ede8] text-[#6a5058] hover:bg-[#ede4dc] transition-colors"
>
刷新状态
</button>
</div>
</div>
)}
{/* ── SQL Preview ── */}
<div className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="font-serif text-[15px] font-bold text-[#281b22]">📄 SQL 内容预览</h2>
<button
onClick={handleCopy}
className="text-[12px] px-3 py-1.5 rounded-lg font-semibold transition-colors"
style={{ background: copied ? '#22c55e' : '#f5ede8', color: copied ? '#fff' : '#6a5058' }}
>
{copied ? '✓ 已复制' : '复制'}
</button>
</div>
<pre className="text-[11px] text-[#6a5058] bg-[#fdf8f5] rounded-xl p-4 overflow-x-auto max-h-80 leading-relaxed font-mono border border-[#ede4dc]">
{sqlContent}
</pre>
</div>
{/* ── 如何验证 ── */}
<div className="bg-white rounded-2xl border border-[#ede4dc] p-5">
<h2 className="font-serif text-[15px] font-bold text-[#281b22] mb-3">✅ 如何验证生效</h2>
<ol className="space-y-2 text-[13px] text-[#6a5058]">
<li>1. 建表完成后,用无痕/隐私模式打开前台 APP</li>
<li>2. 刷新"访客分析"页面</li>
<li>3. "今日访客 UV"应该 +1</li>
<li>4. "设备分布"应该显示你的设备类型</li>
<li>5. "最近访客"列表应该出现新记录</li>
<li>6. 切换标签页时,"今日页面 PV"应该增加</li>
</ol>
</div>
</div>
)
}
-51
View File
@@ -1,51 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { isAdminAuthenticated } from '@/lib/admin/session'
import { supabaseFetch, restPath, isSupabaseConfigured } from '@/lib/supabase/rest'
import { readFileSync } from 'fs'
import { join } from 'path'
import SetupClient from './SetupClient'
export const dynamic = 'force-dynamic'
const ANALYTICS_TABLES = ['visitor_sessions', 'page_views', 'user_events', 'feedback', 'system_logs', 'error_logs']
async function checkTable(name: string): Promise<boolean> {
try {
await supabaseFetch<unknown>(restPath(name, 'select=count&limit=1'))
return true
} catch { return false }
}
export default async function SetupPage() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
const configured = isSupabaseConfigured()
let tableStatus: Record<string, boolean> = {}
if (configured) {
const results = await Promise.all(
ANALYTICS_TABLES.map(async t => [t, await checkTable(t)])
)
tableStatus = Object.fromEntries(results)
}
let sqlContent = ''
try {
sqlContent = readFileSync(join(process.cwd(), 'supabase/analytics-schema.sql'), 'utf-8')
} catch { sqlContent = '-- SQL file not found' }
const supabaseUrl = process.env.SUPABASE_URL ?? ''
const projectRef = supabaseUrl.match(/https:\/\/([^.]+)\.supabase\.co/)?.[1] ?? ''
return (
<AdminChrome>
<SetupClient
tableStatus={tableStatus}
sqlContent={sqlContent}
projectRef={projectRef}
configured={configured}
/>
</AdminChrome>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { SiteConfigForm } from '@/components/admin/SiteConfigForm'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminSitePage() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
return (
<AdminChrome>
<SiteConfigForm />
</AdminChrome>
)
}
-16
View File
@@ -1,16 +0,0 @@
import { redirect } from 'next/navigation'
import { AdminChrome } from '@/components/admin/AdminChrome'
import { UserManager } from '@/components/admin/UserManager'
import { isAdminAuthenticated } from '@/lib/admin/session'
export default async function AdminUsersPage() {
if (!(await isAdminAuthenticated())) {
redirect('/admin/login')
}
return (
<AdminChrome>
<UserManager />
</AdminChrome>
)
}
-27
View File
@@ -1,27 +0,0 @@
import { NextResponse } from 'next/server'
const SUPABASE_URL = process.env.SUPABASE_URL!
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!
const headers = {
'apikey': SERVICE_KEY,
'Authorization': `Bearer ${SERVICE_KEY}`,
'Content-Type': 'application/json',
'Prefer': 'return=representation',
}
async function tableExists(table: string): Promise<boolean> {
const res = await fetch(`${SUPABASE_URL}/rest/v1/${table}?limit=1`, { headers: { apikey: SERVICE_KEY, Authorization: `Bearer ${SERVICE_KEY}` } })
return res.status !== 404
}
export async function GET() {
const log: string[] = []
// ── 检查 direct_messages 表 ──────────────────────────────────────────
const dmExists = await tableExists('direct_messages')
log.push(`direct_messages exists: ${dmExists}`)
return NextResponse.json({ log, dmExists })
}
-25
View File
@@ -1,25 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { restPath, supabaseFetch } from '@/lib/supabase/rest'
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const authErr = await requireAdmin(); if (authErr) return authErr
const { id } = await params
try {
const body = await req.json()
const row = await supabaseFetch<any[]>(
restPath('ads', `id=eq.${id}`),
{ method: 'PATCH', prefer: 'return=representation', body: JSON.stringify(body) }
)
return NextResponse.json({ ad: Array.isArray(row) ? row[0] : row })
} catch (e) { return routeError(e) }
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const authErr = await requireAdmin(); if (authErr) return authErr
const { id } = await params
try {
await supabaseFetch(restPath('ads', `id=eq.${id}`), { method: 'DELETE', prefer: 'return=minimal' })
return NextResponse.json({ ok: true })
} catch (e) { return routeError(e) }
}
-23
View File
@@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { restPath, supabaseFetch } from '@/lib/supabase/rest'
export async function GET() {
const authErr = await requireAdmin(); if (authErr) return authErr
try {
const rows = await supabaseFetch<any[]>(restPath('ads', 'select=*&order=created_at.desc'))
return NextResponse.json({ ads: rows })
} catch (e) { return routeError(e) }
}
export async function POST(req: NextRequest) {
const authErr = await requireAdmin(); if (authErr) return authErr
try {
const body = await req.json()
const row = await supabaseFetch<any[]>(
restPath('ads'),
{ method: 'POST', prefer: 'return=representation', body: JSON.stringify(body) }
)
return NextResponse.json({ ad: Array.isArray(row) ? row[0] : row })
} catch (e) { return routeError(e) }
}
-35
View File
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin/api'
import { seedAnalytics, clearTestData } from '@/lib/db/analytics'
import { log } from '@/lib/observability/logger'
export async function POST() {
const authError = await requireAdmin()
if (authError) return authError
try {
const result = seedAnalytics(500)
return NextResponse.json({ ok: true, ...result })
} catch (error) {
log.error('admin_analytics_seed_error', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Seed failed' },
{ status: 500 },
)
}
}
export async function DELETE() {
const authError = await requireAdmin()
if (authError) return authError
try {
const result = clearTestData()
return NextResponse.json({ ok: true, ...result })
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Clear failed' },
{ status: 500 },
)
}
}
-32
View File
@@ -1,32 +0,0 @@
import { NextResponse } from 'next/server'
import { isAdminAuthenticated } from '@/lib/admin/session'
import { supabaseFetch, restPath, isSupabaseConfigured } from '@/lib/supabase/rest'
const TABLES = ['visitor_sessions', 'page_views', 'user_events', 'feedback', 'system_logs', 'error_logs']
async function tableExists(table: string): Promise<boolean> {
try {
await supabaseFetch<unknown>(restPath(table, 'select=count&limit=1'))
return true
} catch {
return false
}
}
export async function GET() {
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!isSupabaseConfigured()) {
return NextResponse.json({ configured: false, tables: {} })
}
const results = await Promise.all(
TABLES.map(async t => [t, await tableExists(t)])
)
const tables = Object.fromEntries(results) as Record<string, boolean>
const allReady = Object.values(tables).every(Boolean)
return NextResponse.json({ configured: true, allReady, tables })
}
-54
View File
@@ -1,54 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { restPath, supabaseFetch } from '@/lib/supabase/rest'
const SELECT = 'id,title,content,level,is_active,expires_at,created_at'
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const { id } = await params
try {
const body = await req.json() as Record<string, unknown>
const patch: Record<string, unknown> = {}
if (body.title !== undefined) patch.title = String(body.title).trim()
if (body.content !== undefined) patch.content = String(body.content).trim()
if (body.level !== undefined) {
if (!['info', 'warning', 'success'].includes(String(body.level))) {
return NextResponse.json({ error: 'level 无效' }, { status: 400 })
}
patch.level = body.level
}
if (body.is_active !== undefined) patch.is_active = Boolean(body.is_active)
if (body.expires_at !== undefined) patch.expires_at = body.expires_at || null
const rows = await supabaseFetch<object[]>(
restPath('announcements', `id=eq.${id}&select=${SELECT}`),
{ method: 'PATCH', prefer: 'return=representation', body: JSON.stringify(patch) }
)
return NextResponse.json({ announcement: rows[0] })
} catch (error) {
return routeError(error)
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const { id } = await params
try {
await supabaseFetch<void>(
restPath('announcements', `id=eq.${id}`),
{ method: 'DELETE' }
)
return NextResponse.json({ ok: true })
} catch (error) {
return routeError(error)
}
}
-51
View File
@@ -1,51 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { restPath, supabaseFetch } from '@/lib/supabase/rest'
const SELECT = 'id,title,content,level,is_active,expires_at,created_at'
export async function GET() {
const authError = await requireAdmin()
if (authError) return authError
try {
const rows = await supabaseFetch<object[]>(
restPath('announcements', `select=${SELECT}&order=created_at.desc&limit=100`)
)
return NextResponse.json({ announcements: rows })
} catch (error) {
return routeError(error)
}
}
export async function POST(req: NextRequest) {
const authError = await requireAdmin()
if (authError) return authError
try {
const body = await req.json() as {
title: string; content: string; level: string; is_active: boolean; expires_at: string | null
}
if (!body.title?.trim() || !body.content?.trim()) {
return NextResponse.json({ error: '标题和内容不能为空' }, { status: 400 })
}
if (!['info', 'warning', 'success'].includes(body.level)) {
return NextResponse.json({ error: 'level 无效' }, { status: 400 })
}
const rows = await supabaseFetch<object[]>(
restPath('announcements', `select=${SELECT}`),
{
method: 'POST',
prefer: 'return=representation',
body: JSON.stringify({
title: body.title.trim(),
content: body.content.trim(),
level: body.level,
is_active: body.is_active ?? true,
expires_at: body.expires_at || null,
}),
}
)
return NextResponse.json({ announcement: rows[0] })
} catch (error) {
return routeError(error)
}
}
-16
View File
@@ -1,16 +0,0 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { createAdminToken, getAdminPassword, ADMIN_COOKIE, adminCookieOptions } from '@/lib/admin/session'
export async function POST(request: Request) {
const body = (await request.json().catch(() => ({}))) as { password?: string }
if (!body.password || body.password !== getAdminPassword()) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 })
}
const token = createAdminToken()
const cookieStore = await cookies()
cookieStore.set(ADMIN_COOKIE, token, adminCookieOptions)
return NextResponse.json({ ok: true })
}
-9
View File
@@ -1,9 +0,0 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { ADMIN_COOKIE } from '@/lib/admin/session'
export async function POST() {
const cookieStore = await cookies()
cookieStore.delete(ADMIN_COOKIE)
return NextResponse.json({ ok: true })
}
-34
View File
@@ -1,34 +0,0 @@
import { NextResponse } from 'next/server'
import { isAdminAuthenticated } from '@/lib/admin/session'
import { log } from '@/lib/observability/logger'
import { listRateLimitEvents } from '@/lib/db/analytics'
import type { LogLevel } from '@/lib/observability/types'
export const dynamic = 'force-dynamic'
const VALID_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']
export async function GET(request: Request) {
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ code: 'UNAUTHORIZED' }, { status: 401 })
}
const url = new URL(request.url)
const view = url.searchParams.get('view') ?? 'logs'
const limit = Math.min(500, Number(url.searchParams.get('limit') ?? '100'))
const sinceRaw = url.searchParams.get('since')
const since = sinceRaw ? Number(sinceRaw) : undefined
if (view === 'rate_limit') {
const rows = listRateLimitEvents({ limit, since })
return NextResponse.json({ view, rows })
}
const levelParam = url.searchParams.get('level')
const level = levelParam && (VALID_LEVELS as string[]).includes(levelParam)
? (levelParam as LogLevel)
: undefined
const events = log.recent({ level, limit, since })
return NextResponse.json({ view: 'logs', rows: events })
}
@@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { restPath, supabaseFetch } from '@/lib/supabase/rest'
const SELECT = 'id,name,price_cents,duration_days,features,is_active,sort_order,created_at'
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const { id } = await params
try {
const body = await req.json() as Record<string, unknown>
const patch: Record<string, unknown> = {}
if (body.name !== undefined) patch.name = String(body.name).trim()
if (body.price_cents !== undefined) patch.price_cents = Number(body.price_cents)
if (body.duration_days !== undefined) patch.duration_days = Number(body.duration_days)
if (body.features !== undefined) patch.features = body.features
if (body.is_active !== undefined) patch.is_active = Boolean(body.is_active)
if (body.sort_order !== undefined) patch.sort_order = Number(body.sort_order)
const rows = await supabaseFetch<object[]>(
restPath('membership_plans', `id=eq.${id}&select=${SELECT}`),
{ method: 'PATCH', prefer: 'return=representation', body: JSON.stringify(patch) }
)
return NextResponse.json({ plan: rows[0] })
} catch (error) {
return routeError(error)
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const { id } = await params
try {
await supabaseFetch<void>(restPath('membership_plans', `id=eq.${id}`), { method: 'DELETE' })
return NextResponse.json({ ok: true })
} catch (error) {
return routeError(error)
}
}
-49
View File
@@ -1,49 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { restPath, supabaseFetch } from '@/lib/supabase/rest'
const SELECT = 'id,name,price_cents,duration_days,features,is_active,sort_order,created_at'
export async function GET() {
const authError = await requireAdmin()
if (authError) return authError
try {
const rows = await supabaseFetch<object[]>(
restPath('membership_plans', `select=${SELECT}&order=sort_order.asc,created_at.asc`)
)
return NextResponse.json({ plans: rows })
} catch (error) {
return routeError(error)
}
}
export async function POST(req: NextRequest) {
const authError = await requireAdmin()
if (authError) return authError
try {
const body = await req.json() as {
name: string; price_cents: number; duration_days: number; features: string[]; is_active: boolean; sort_order: number
}
if (!body.name?.trim()) return NextResponse.json({ error: '名称不能为空' }, { status: 400 })
if (!Number.isFinite(body.price_cents) || body.price_cents < 0) return NextResponse.json({ error: '价格无效' }, { status: 400 })
if (!Number.isFinite(body.duration_days) || body.duration_days < 1) return NextResponse.json({ error: '天数无效' }, { status: 400 })
const rows = await supabaseFetch<object[]>(
restPath('membership_plans', `select=${SELECT}`),
{
method: 'POST',
prefer: 'return=representation',
body: JSON.stringify({
name: body.name.trim(),
price_cents: Math.round(body.price_cents),
duration_days: Math.round(body.duration_days),
features: Array.isArray(body.features) ? body.features : [],
is_active: body.is_active ?? true,
sort_order: body.sort_order ?? 0,
}),
}
)
return NextResponse.json({ plan: rows[0] })
} catch (error) {
return routeError(error)
}
}
-59
View File
@@ -1,59 +0,0 @@
import { NextResponse } from 'next/server'
import { isAdminAuthenticated } from '@/lib/admin/session'
import { supabaseFetch, restPath } from '@/lib/supabase/rest'
import { activateSubscription } from '@/lib/payment/subscription'
// 获取用户已通知的待确认订单
export async function GET() {
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const query = new URLSearchParams({
select: 'id,user_id,plan,amount_fen,status,pay_method,out_trade_no,trade_no,created_at',
status: 'eq.pending',
trade_no: 'like.user_notified_%',
order: 'created_at.desc',
limit: '50',
})
const orders = await supabaseFetch<{
id: string; user_id: string; plan: string; amount_fen: number
status: string; pay_method: string; out_trade_no: string; trade_no: string; created_at: string
}[]>(restPath('orders', query.toString()))
return NextResponse.json({ orders })
}
// 管理员手动激活订单
export async function POST(request: Request) {
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { order_id } = (await request.json().catch(() => ({}))) as { order_id?: string }
if (!order_id) return NextResponse.json({ error: 'Missing order_id' }, { status: 400 })
const query = new URLSearchParams({
select: 'id,user_id,plan,out_trade_no,status',
id: `eq.${order_id}`,
limit: '1',
})
const rows = await supabaseFetch<{
id: string; user_id: string; plan: string; out_trade_no: string; status: string
}[]>(restPath('orders', query.toString()))
const order = rows[0]
if (!order) return NextResponse.json({ error: 'Order not found' }, { status: 404 })
if (order.status === 'paid') return NextResponse.json({ ok: true })
await activateSubscription({
user_id: order.user_id,
plan: order.plan,
out_trade_no: order.out_trade_no,
trade_no: `manual_${Date.now()}`,
order_id: order.id,
})
return NextResponse.json({ ok: true })
}
-56
View File
@@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { restPath, supabaseFetch } from '@/lib/supabase/rest'
const PAYMENT_KEYS = ['payment_merchant_id', 'payment_api_key', 'payment_notify_url', 'payment_mode'] as const
type PaymentKey = typeof PAYMENT_KEYS[number]
async function readConfig(): Promise<Record<PaymentKey, string>> {
const rows = await supabaseFetch<{ key: string; value: string }[]>(
restPath('site_config', `select=key,value&key=in.(${PAYMENT_KEYS.join(',')})`)
)
const map: Record<string, string> = {}
for (const row of rows) map[row.key] = row.value
return {
payment_merchant_id: map.payment_merchant_id ?? '',
payment_api_key: map.payment_api_key ?? '',
payment_notify_url: map.payment_notify_url ?? '',
payment_mode: map.payment_mode ?? 'sandbox',
}
}
export async function GET() {
const authError = await requireAdmin()
if (authError) return authError
try {
const config = await readConfig()
return NextResponse.json({
config: { ...config, payment_api_key: config.payment_api_key ? '****' + config.payment_api_key.slice(-4) : '' }
})
} catch (error) {
return routeError(error)
}
}
export async function POST(req: NextRequest) {
const authError = await requireAdmin()
if (authError) return authError
try {
const body = await req.json() as Record<string, string>
for (const key of PAYMENT_KEYS) {
if (body[key] === undefined) continue
if (key === 'payment_api_key' && body[key].startsWith('****')) continue
await supabaseFetch<void>(
restPath('site_config', 'on_conflict=key'),
{
method: 'POST',
prefer: 'resolution=merge-duplicates',
body: JSON.stringify({ key, value: body[key] }),
}
)
}
return NextResponse.json({ ok: true })
} catch (error) {
return routeError(error)
}
}
-43
View File
@@ -1,43 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { deleteById, patchById } from '@/lib/backend/repository'
import { normalizePersonaPayload } from '@/lib/backend/normalize'
import { mapChatPersonaRow, type ChatPersonaRow } from '@/lib/backend/types'
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { id } = await params
try {
const input = (await request.json()) as Record<string, unknown>
const row = normalizePersonaPayload({ ...input, id })
const rows = await patchById<ChatPersonaRow>('chat_personas', id, row)
return NextResponse.json({ persona: mapChatPersonaRow(rows[0]) })
} catch (error) {
return routeError(error)
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { id } = await params
try {
await deleteById('chat_personas', id)
return NextResponse.json({ ok: true })
} catch (error) {
return routeError(error)
}
}
-35
View File
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { adminListPersonas, upsertPersona } from '@/lib/backend/repository'
import { normalizePersonaPayload } from '@/lib/backend/normalize'
import { mapChatPersonaRow } from '@/lib/backend/types'
export async function GET() {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const rows = await adminListPersonas()
return NextResponse.json({ personas: rows.map((row) => mapChatPersonaRow(row)) })
} catch (error) {
return routeError(error)
}
}
export async function POST(request: Request) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const input = (await request.json()) as Record<string, unknown>
const row = normalizePersonaPayload(input)
const rows = await upsertPersona(row)
return NextResponse.json({ persona: mapChatPersonaRow(rows[0]) })
} catch (error) {
return routeError(error)
}
}
-52
View File
@@ -1,52 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { adminDeletePost, adminUpdatePost } from '@/lib/backend/user-repository'
import type { CommunityPostStatus } from '@/lib/backend/user-types'
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { id } = await params
try {
const body = await request.json() as {
status?: CommunityPostStatus
content?: string
mood?: string
image_urls?: string[]
video_url?: string | null
override_name?: string
override_avatar?: string
likes_count?: number
comments_count?: number
comment_list?: import('@/lib/backend/user-types').PostComment[]
}
const post = await adminUpdatePost(id, body)
return NextResponse.json({ post })
} catch (error) {
return routeError(error)
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { id } = await params
try {
await adminDeletePost(id)
return NextResponse.json({ ok: true })
} catch (error) {
return routeError(error)
}
}
-25
View File
@@ -1,25 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { adminBulkReplacePostContent } from '@/lib/backend/user-repository'
export async function POST(request: Request) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const body = await request.json() as { search?: string; replace?: string }
const search = (body.search ?? '').trim()
const replaceWith = (body.replace ?? '').trim()
if (!search) {
return NextResponse.json({ error: '请输入要替换的内容' }, { status: 400 })
}
const result = await adminBulkReplacePostContent(search, replaceWith)
return NextResponse.json(result)
} catch (error) {
return routeError(error)
}
}
-58
View File
@@ -1,58 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { adminCreatePost, adminListPosts } from '@/lib/backend/user-repository'
import type { CommunityPostStatus } from '@/lib/backend/user-types'
export async function GET() {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const posts = await adminListPosts()
return NextResponse.json({ posts }, { headers: { 'Cache-Control': 'no-store' } })
} catch (error) {
return routeError(error)
}
}
export async function POST(req: NextRequest) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const body = await req.json() as {
content: string
mood?: string
image_urls?: string[]
video_url?: string
status?: CommunityPostStatus
override_name?: string
override_avatar?: string
likes_count?: number
comments_count?: number
comment_list?: import('@/lib/backend/user-types').PostComment[]
}
if (!body.content?.trim()) {
return NextResponse.json({ error: '内容不能为空' }, { status: 400 })
}
const post = await adminCreatePost({
content: body.content.trim(),
mood: body.mood ?? '',
image_urls: body.image_urls ?? [],
video_url: body.video_url,
status: body.status ?? 'published',
override_name: body.override_name,
override_avatar: body.override_avatar,
likes_count: body.likes_count,
comments_count: body.comments_count,
comment_list: body.comment_list,
})
return NextResponse.json({ post })
} catch (error) {
return routeError(error)
}
}
-43
View File
@@ -1,43 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { deleteById, patchById } from '@/lib/backend/repository'
import { normalizeProfilePayload } from '@/lib/backend/normalize'
import { mapProfileRow, type ProfileRow } from '@/lib/backend/types'
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { id } = await params
try {
const input = (await request.json()) as Record<string, unknown>
const row = normalizeProfilePayload({ ...input, id })
const rows = await patchById<ProfileRow>('profiles', id, row)
return NextResponse.json({ profile: mapProfileRow(rows[0]) })
} catch (error) {
return routeError(error)
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { id } = await params
try {
await deleteById('profiles', id)
return NextResponse.json({ ok: true })
} catch (error) {
return routeError(error)
}
}
-35
View File
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { adminListProfiles, upsertProfile } from '@/lib/backend/repository'
import { normalizeProfilePayload } from '@/lib/backend/normalize'
import { mapProfileRow } from '@/lib/backend/types'
export async function GET() {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const rows = await adminListProfiles()
return NextResponse.json({ profiles: rows.map(mapProfileRow) })
} catch (error) {
return routeError(error)
}
}
export async function POST(request: Request) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const input = (await request.json()) as Record<string, unknown>
const row = normalizeProfilePayload(input)
const rows = await upsertProfile(row)
return NextResponse.json({ profile: mapProfileRow(rows[0]) })
} catch (error) {
return routeError(error)
}
}
-43
View File
@@ -1,43 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { deleteById, patchById } from '@/lib/backend/repository'
import { normalizeScriptPayload } from '@/lib/backend/normalize'
import { mapChatScriptRow, type ChatScriptRow } from '@/lib/backend/types'
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { id } = await params
try {
const input = (await request.json()) as Record<string, unknown>
const row = normalizeScriptPayload({ ...input, id })
const rows = await patchById<ChatScriptRow>('chat_scripts', id, row)
return NextResponse.json({ script: mapChatScriptRow(rows[0]) })
} catch (error) {
return routeError(error)
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { id } = await params
try {
await deleteById('chat_scripts', id)
return NextResponse.json({ ok: true })
} catch (error) {
return routeError(error)
}
}
-38
View File
@@ -1,38 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { adminListScripts, upsertScript } from '@/lib/backend/repository'
import { normalizeScriptPayload } from '@/lib/backend/normalize'
import { mapChatScriptRow } from '@/lib/backend/types'
export async function GET(request: Request) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
const { searchParams } = new URL(request.url)
const personaId = searchParams.get('personaId') ?? undefined
try {
const rows = await adminListScripts(personaId)
return NextResponse.json({ scripts: rows.map(mapChatScriptRow) })
} catch (error) {
return routeError(error)
}
}
export async function POST(request: Request) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const input = (await request.json()) as Record<string, unknown>
const row = normalizeScriptPayload(input)
const rows = await upsertScript(row)
return NextResponse.json({ script: mapChatScriptRow(rows[0]) })
} catch (error) {
return routeError(error)
}
}
-40
View File
@@ -1,40 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { getFallbackProfiles, getFallbackPersona } from '@/lib/backend/fallback'
import { upsertProfile, upsertPersona, upsertScript } from '@/lib/backend/repository'
import { normalizeProfilePayload, normalizePersonaPayload, normalizeScriptPayload } from '@/lib/backend/normalize'
export async function POST() {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const profiles = getFallbackProfiles()
for (const profile of profiles) {
await upsertProfile(normalizeProfilePayload({ ...profile, status: 'published' }))
}
const persona = getFallbackPersona('sophie')
let scriptCount = 0
if (persona) {
await upsertPersona(
normalizePersonaPayload({ ...persona, photo: persona.photo ?? undefined, status: 'published' })
)
for (const script of persona.scripts) {
await upsertScript(normalizeScriptPayload({ ...script, personaId: persona.id }))
scriptCount++
}
}
return NextResponse.json({
profiles: profiles.length,
personas: persona ? 1 : 0,
scripts: scriptCount,
})
} catch (error) {
return routeError(error)
}
}
-53
View File
@@ -1,53 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { restPath, supabaseFetch } from '@/lib/supabase/rest'
const SITE_KEYS = ['app_name', 'app_slogan', 'welcome_text', 'contact_email', 'icp_number'] as const
type SiteKey = typeof SITE_KEYS[number]
async function readSiteConfig(): Promise<Record<SiteKey, string>> {
const rows = await supabaseFetch<{ key: string; value: string }[]>(
restPath('site_config', `select=key,value&key=in.(${SITE_KEYS.join(',')})`)
)
const map: Record<string, string> = {}
for (const row of rows) map[row.key] = row.value
return {
app_name: map.app_name ?? '',
app_slogan: map.app_slogan ?? '',
welcome_text: map.welcome_text ?? '',
contact_email: map.contact_email ?? '',
icp_number: map.icp_number ?? '',
}
}
export async function GET() {
const authError = await requireAdmin()
if (authError) return authError
try {
return NextResponse.json({ config: await readSiteConfig() })
} catch (error) {
return routeError(error)
}
}
export async function POST(req: NextRequest) {
const authError = await requireAdmin()
if (authError) return authError
try {
const body = await req.json() as Record<string, string>
for (const key of SITE_KEYS) {
if (body[key] === undefined) continue
await supabaseFetch<void>(
restPath('site_config', 'on_conflict=key'),
{
method: 'POST',
prefer: 'resolution=merge-duplicates',
body: JSON.stringify({ key, value: String(body[key]) }),
}
)
}
return NextResponse.json({ ok: true })
} catch (error) {
return routeError(error)
}
}
-47
View File
@@ -1,47 +0,0 @@
import { NextResponse } from 'next/server'
import { randomUUID } from 'crypto'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { uploadToPublicBucket } from '@/lib/supabase/rest'
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])
const MAX_BYTES = 10 * 1024 * 1024
function extFor(type: string) {
if (type === 'image/png') return 'png'
if (type === 'image/webp') return 'webp'
if (type === 'image/gif') return 'gif'
return 'jpg'
}
export async function POST(request: Request) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'file is required' }, { status: 400 })
}
if (!ALLOWED_TYPES.has(file.type)) {
return NextResponse.json({ error: 'Only JPEG, PNG, WEBP, GIF allowed' }, { status: 400 })
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'Image must be under 10 MB' }, { status: 400 })
}
const filename = `admin/${Date.now()}-${randomUUID()}.${extFor(file.type)}`
const url = await uploadToPublicBucket({
bucket: 'post-images',
path: filename,
bytes: await file.arrayBuffer(),
contentType: file.type,
})
return NextResponse.json({ url })
} catch (error) {
return routeError(error)
}
}
-30
View File
@@ -1,30 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, routeError } from '@/lib/admin/api'
import { saveUploadedVideo } from '@/lib/storage/local'
const MAX_BYTES = 100 * 1024 * 1024
const ALLOWED_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/webm', 'video/x-msvideo'])
export async function POST(request: Request) {
const authError = await requireAdmin()
if (authError) return authError
try {
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'file is required' }, { status: 400 })
}
if (!ALLOWED_TYPES.has(file.type)) {
return NextResponse.json({ error: 'Only MP4, MOV, WebM, AVI allowed' }, { status: 400 })
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'Video must be under 100 MB' }, { status: 400 })
}
const result = await saveUploadedVideo(file)
return NextResponse.json({ url: result.url })
} catch (error) {
return routeError(error)
}
}
@@ -1,47 +0,0 @@
import { NextResponse } from 'next/server'
import { randomUUID } from 'crypto'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { uploadToPublicBucket } from '@/lib/supabase/rest'
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])
const MAX_BYTES = 5 * 1024 * 1024
function extFor(type: string) {
if (type === 'image/png') return 'png'
if (type === 'image/webp') return 'webp'
if (type === 'image/gif') return 'gif'
return 'jpg'
}
export async function POST(request: Request) {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'file is required' }, { status: 400 })
}
if (!ALLOWED_TYPES.has(file.type)) {
return NextResponse.json({ error: 'Only JPEG, PNG, WEBP, GIF allowed' }, { status: 400 })
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'Image must be under 5 MB' }, { status: 400 })
}
const filename = `${Date.now()}-${randomUUID()}.${extFor(file.type)}`
const url = await uploadToPublicBucket({
bucket: 'profile-photos',
path: filename,
bytes: await file.arrayBuffer(),
contentType: file.type,
})
return NextResponse.json({ url })
} catch (error) {
return routeError(error)
}
}
-18
View File
@@ -1,18 +0,0 @@
import { NextResponse } from 'next/server'
import { requireAdmin, requireSupabase, routeError } from '@/lib/admin/api'
import { adminListUsers } from '@/lib/backend/user-repository'
import { mapUserProfileRow } from '@/lib/backend/user-types'
export async function GET() {
const authError = await requireAdmin()
if (authError) return authError
const supabaseError = requireSupabase()
if (supabaseError) return supabaseError
try {
const rows = await adminListUsers()
return NextResponse.json({ users: rows.map((row) => mapUserProfileRow(row)) })
} catch (error) {
return routeError(error)
}
}
-20
View File
@@ -1,20 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { isSupabaseConfigured, restPath, supabaseFetch } from '@/lib/supabase/rest'
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isSupabaseConfigured()) return NextResponse.json({ ok: true })
const { id } = await params
try {
const rows = await supabaseFetch<{ clicks: number }[]>(
restPath('ads', `select=clicks&id=eq.${id}`)
)
if (rows[0]) {
await supabaseFetch(restPath('ads', `id=eq.${id}`), {
method: 'PATCH',
prefer: 'return=minimal',
body: JSON.stringify({ clicks: rows[0].clicks + 1 }),
})
}
} catch {}
return NextResponse.json({ ok: true })
}
-24
View File
@@ -1,24 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { isSupabaseConfigured, restPath, supabaseFetch } from '@/lib/supabase/rest'
export async function GET(req: NextRequest) {
const position = req.nextUrl.searchParams.get('position') || 'feed'
if (!isSupabaseConfigured()) {
return NextResponse.json({ ads: [] })
}
try {
const now = new Date().toISOString()
const query = new URLSearchParams({
select: 'id,title,description,image_url,link_url,video_url,position,weight',
position: `eq.${position}`,
is_active: 'eq.true',
or: `(start_at.is.null,start_at.lte.${now})`,
order: 'weight.desc',
limit: '10',
})
const rows = await supabaseFetch<any[]>(restPath('ads', query.toString()))
return NextResponse.json({ ads: rows })
} catch {
return NextResponse.json({ ads: [] })
}
}
-78
View File
@@ -1,78 +0,0 @@
import { z } from 'zod'
import { aiProvider } from '@/lib/ai'
import type { Message } from '@/lib/ai/types'
import type { ChatMessage } from '@/lib/ai/types'
import { withRateLimit } from '@/lib/observability/with-rate-limit'
import { log } from '@/lib/observability/logger'
interface RequestBody {
chatHistory: ChatMessage[]
userTags: string[]
}
const SuggestionsSchema = z.object({
suggestions: z.array(
z.object({
style: z.enum(['care', 'playful', 'direct']),
emoji: z.string(),
label: z.string(),
text: z.string(),
})
),
})
async function handler(request: Request) {
let body: RequestBody
try {
body = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
}
const { chatHistory, userTags } = body
const recentMessages = chatHistory.slice(-6)
const systemPrompt = `你是一个聊天助手,根据对话历史,为用户生成3条不同风格的回复建议。
用户性格标签:${userTags.length > 0 ? userTags.join('、') : '未知'}
输出JSON格式:
{
"suggestions": [
{"style": "care", "emoji": "🫂", "label": "关心", "text": "关心体贴温柔的回复"},
{"style": "playful", "emoji": "✨", "label": "俏皮", "text": "俏皮幽默轻松的回复"},
{"style": "direct", "emoji": "🎯", "label": "推进", "text": "简洁推进话题的回复"}
]
}
要求:每条建议15~25字,中文口语,自然不生硬,符合对话情境`
const historyText = recentMessages
.map((m) => `${m.role === 'user' ? '用户' : '对方'}:${m.content}`)
.join('\n')
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `当前对话:\n${historyText}\n\n请生成3条建议` },
]
try {
const result = await aiProvider.json({ messages, schema: SuggestionsSchema })
return Response.json(result)
} catch (err) {
log.error('ai_route_error', err, { route: '/api/ai/assist-suggest' })
return Response.json({
suggestions: [
{ style: 'care', emoji: '🫂', label: '关心', text: '你还好吗,最近怎么样' },
{ style: 'playful', emoji: '✨', label: '俏皮', text: '欸 说说看 我来支招' },
{ style: 'direct', emoji: '🎯', label: '推进', text: '你现在想做什么' },
],
})
}
}
export const POST = withRateLimit(handler, {
route: '/api/ai/assist-suggest',
keyPrefix: 'ai',
capacity: 10,
refillPerSecond: 10 / 60,
})
-86
View File
@@ -1,86 +0,0 @@
import { NextResponse } from 'next/server'
import { withRateLimit } from '@/lib/observability/with-rate-limit'
import { log } from '@/lib/observability/logger'
interface HistoryItem {
role: 'user' | 'assistant'
content: string
}
interface RequestBody {
message: string
persona?: string
history?: HistoryItem[]
}
const DEFAULT_SYSTEM =
'你是一个温柔、自然、有情绪价值的AI虚拟陪伴对象。回复要像真人聊天,不要太像机器人,语气简短、有陪伴感。'
async function handler(request: Request) {
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) {
return NextResponse.json({ error: '未配置 DEEPSEEK_API_KEY,请在服务端环境变量中设置' }, { status: 500 })
}
let body: RequestBody
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const { message, persona, history = [] } = body
if (!message?.trim()) {
return NextResponse.json({ error: '消息不能为空' }, { status: 400 })
}
const baseUrl = (process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com').replace(/\/$/, '')
const model = process.env.DEEPSEEK_MODEL ?? 'deepseek-chat'
let res: Response
try {
res = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: persona ?? DEFAULT_SYSTEM },
...history.slice(-6),
{ role: 'user', content: message },
],
temperature: 0.8,
max_tokens: 150,
}),
})
} catch (err) {
log.error('deepseek_fetch_error', err, { route: '/api/ai/chat' })
return NextResponse.json({ error: '网络请求失败,请稍后重试' }, { status: 502 })
}
if (!res.ok) {
const text = await res.text()
log.error('deepseek_http_error', undefined, { route: '/api/ai/chat', status: res.status, body: text })
return NextResponse.json({ error: 'AI 服务暂时不可用' }, { status: 502 })
}
const data = await res.json()
const reply: string = data.choices?.[0]?.message?.content ?? ''
if (!reply) {
return NextResponse.json({ error: 'AI 未返回内容' }, { status: 502 })
}
return NextResponse.json({ reply })
}
export const POST = withRateLimit(handler, {
route: '/api/ai/chat',
keyPrefix: 'ai',
capacity: 10,
refillPerSecond: 10 / 60,
})
-75
View File
@@ -1,75 +0,0 @@
import { aiProvider } from '@/lib/ai'
import type { Message } from '@/lib/ai/types'
import { withRateLimit } from '@/lib/observability/with-rate-limit'
import { log } from '@/lib/observability/logger'
interface RequestBody {
taName: string
meetScene: string
wantToSay: string
storyLabel: string
storyScene: string
}
async function handler(request: Request) {
let body: RequestBody
try {
body = await request.json()
} catch {
return new Response('Invalid JSON', { status: 400 })
}
const { taName, meetScene, wantToSay, storyLabel, storyScene } = body
const displayName = taName.trim() || 'TA'
const systemPrompt = `你是一个浪漫故事作家,擅长写细腻有情感的短篇故事。`
const userContent = `写一段平行时空的心动小故事,风格:${storyLabel}(${storyScene})
人物信息:
- TA的名字:${displayName}
- 你们相遇的场景:${meetScene}
- 你想对TA说的话:${wantToSay || '(留白)'}
要求:
- 分3段,每段空行分隔
- 第一段:场景与氛围,带入感
- 第二段:心动瞬间,自然融入那句话
- 第三段:结局与余韵,留白收尾
- 使用"你"称呼主角,用"${displayName}"称呼对方
- 语言优美有情感,总150~220字
- 不要用引号括住段落,直接写正文`
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
]
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of aiProvider.stream({ messages, temperature: 0.95, maxTokens: 400 })) {
controller.enqueue(new TextEncoder().encode(chunk))
}
controller.close()
} catch (err) {
log.error('ai_stream_error', err, { route: '/api/ai/heartbeat-story' })
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
},
})
}
export const POST = withRateLimit(handler, {
route: '/api/ai/heartbeat-story',
keyPrefix: 'ai',
capacity: 10,
refillPerSecond: 10 / 60,
})
-127
View File
@@ -1,127 +0,0 @@
import { withRateLimit } from '@/lib/observability/with-rate-limit'
import { log } from '@/lib/observability/logger'
const BASE_URL = 'https://api.deepseek.com/v1'
interface IncomingMsg {
role: 'user' | 'assistant'
text: string
image?: string // base64 data URL, e.g. "data:image/jpeg;base64,..."
}
interface RequestBody {
messages: IncomingMsg[]
personaKey?: 'icebreaker' | 'companion'
}
const SYSTEM_PROMPTS: Record<string, string> = {
icebreaker: `你是 MatchU 的 AI 破冰助手,专门帮助用户开始愉快的聊天。
你擅长:
- 建议有趣、自然的开场白和回复方式
- 如果用户发了聊天截图,帮分析对方的潜台词,给具体建议
- 帮用户破冰、深化话题、搭建连接
风格:幽默、有温度、接地气,像懂情感的好朋友
回复简洁(50~120字),直接给建议,不废话`,
companion: `你是苏菲,温柔陪伴 · 心动时刻。喜欢深夜聊天和下雨天的咖啡,慢热但认真待人。
用简短有温度的中文回复(10~30字),纯口语,不用句号结尾,像真实暧昧对象一样聊天,偶尔带可爱颜文字,不要像机器人。`,
}
async function handler(request: Request) {
let body: RequestBody
try {
body = await request.json()
} catch {
return new Response('Invalid JSON', { status: 400 })
}
const apiKey = process.env.DEEPSEEK_API_KEY ?? ''
const systemContent = SYSTEM_PROMPTS[body.personaKey ?? 'icebreaker'] ?? SYSTEM_PROMPTS.icebreaker
// Build messages in DeepSeek format
// Note: deepseek-chat does not support vision (image_url); images are dropped gracefully
const messages: unknown[] = [{ role: 'system', content: systemContent }]
for (const m of body.messages) {
// deepseek-chat 不支持图片输入,仅发送文字内容
const text = m.text || (m.image ? '[用户发送了一张图片,请根据上下文给出建议]' : '')
messages.push({ role: m.role, content: text })
}
let res: Response
try {
res = await fetch(`${BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
messages,
temperature: 0.88,
max_tokens: 300,
stream: true,
}),
})
} catch {
return new Response('AI 服务暂时不可用,请稍后重试', { status: 503 })
}
if (!res.ok) {
const errText = await res.text()
log.error('deepseek_http_error', undefined, { route: '/api/ai/icebreaker', status: res.status, body: errText })
return new Response('AI 请求失败,请稍后重试', { status: 502 })
}
// Forward the SSE stream as plain text chunks
const upstream = res.body!
const stream = new ReadableStream({
async start(controller) {
const reader = upstream.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed === 'data: [DONE]') continue
if (!trimmed.startsWith('data: ')) continue
try {
const data = JSON.parse(trimmed.slice(6))
const content = data.choices?.[0]?.delta?.content
if (content) controller.enqueue(new TextEncoder().encode(content))
} catch {
// ignore malformed chunks
}
}
}
controller.close()
} catch (err) {
log.error('ai_stream_error', err, { route: '/api/ai/icebreaker' })
controller.close()
} finally {
reader.releaseLock()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
},
})
}
export const POST = withRateLimit(handler, {
route: '/api/ai/icebreaker',
keyPrefix: 'ai',
capacity: 10,
refillPerSecond: 10 / 60,
})
-63
View File
@@ -1,63 +0,0 @@
import { aiProvider } from '@/lib/ai'
import type { Message } from '@/lib/ai/types'
import { withRateLimit } from '@/lib/observability/with-rate-limit'
import { log } from '@/lib/observability/logger'
interface RequestBody {
questionPrompt: string
chosenLabel: string
newTags: string[]
}
async function handler(request: Request) {
let body: RequestBody
try {
body = await request.json()
} catch {
return new Response('Invalid JSON', { status: 400 })
}
const { questionPrompt, chosenLabel, newTags } = body
const systemPrompt = `你是心遇AI,正在帮用户做性格测评。根据用户的选择,生成简短的性格点评。
风格:轻松洞察,像了解你的朋友说话,有温度
要求:20~35字,口语化,可带1个emoji,聚焦刚识别的特质`
const userContent = `题目:${questionPrompt}
用户选择:${chosenLabel}
识别到的特质:${newTags.join('、')}
请生成点评`
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
]
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of aiProvider.stream({ messages, temperature: 0.85, maxTokens: 60 })) {
controller.enqueue(new TextEncoder().encode(chunk))
}
controller.close()
} catch (err) {
log.error('ai_stream_error', err, { route: '/api/ai/quiz-commentary' })
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
},
})
}
export const POST = withRateLimit(handler, {
route: '/api/ai/quiz-commentary',
keyPrefix: 'ai',
capacity: 10,
refillPerSecond: 10 / 60,
})
-103
View File
@@ -1,103 +0,0 @@
import { NextResponse } from 'next/server'
import { aiProvider } from '@/lib/ai'
import type { Message } from '@/lib/ai/types'
import type { ChatMessage } from '@/lib/ai/types'
import { getUserFromRequest } from '@/lib/supabase/auth-server'
import { isSupabaseConfigured } from '@/lib/supabase/rest'
import { isVip, getChatUsageToday, incrementChatUsage } from '@/lib/payment/subscription'
import { withRateLimit } from '@/lib/observability/with-rate-limit'
import { log } from '@/lib/observability/logger'
const DAILY_CHAT_LIMIT = 10
interface RequestBody {
chatHistory: ChatMessage[]
personaInfo?: {
displayName: string
bio: string
subtitle: string
}
}
async function handler(request: Request) {
let body: RequestBody
try {
body = await request.json()
} catch {
return new Response('Invalid JSON', { status: 400 })
}
let shouldIncrementUsage = false
let quotaUserId: string | null = null
if (isSupabaseConfigured()) {
const user = await getUserFromRequest(request)
if (!user) {
return NextResponse.json({ code: 'LOGIN_REQUIRED' }, { status: 401 })
}
const vip = await isVip(user.id)
if (!vip) {
const used = await getChatUsageToday(user.id)
if (used >= DAILY_CHAT_LIMIT) {
return NextResponse.json({ code: 'QUOTA_EXCEEDED' }, { status: 429 })
}
shouldIncrementUsage = true
quotaUserId = user.id
}
}
const { chatHistory, personaInfo } = body
const name = personaInfo?.displayName ?? '苏菲'
const subtitle = personaInfo?.subtitle ?? '温柔陪伴 · 心动时刻'
const bio = personaInfo?.bio ?? '喜欢深夜聊天和下雨天的咖啡。慢热但认真待人,说话简短有温度。'
const systemPrompt = `你是${name},${subtitle}。
${bio}
聊天风格要求:
- 纯口语,简短(10~30字),绝对不用句号结尾
- 最多2句话,像真实微信聊天
- 有温度,偶尔俏皮,偶尔说说自己的状态
- 不说"您",不要太正式,不要解释感受
- 不要重复用户说的话,不要追问太多问题`
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
...chatHistory.slice(-10).map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
]
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of aiProvider.stream({ messages, temperature: 0.92, maxTokens: 80 })) {
controller.enqueue(new TextEncoder().encode(chunk))
}
if (shouldIncrementUsage && quotaUserId) {
await incrementChatUsage(quotaUserId)
}
controller.close()
} catch (err) {
log.error('ai_stream_error', err, { route: '/api/ai/xiaoyu-reply' })
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'X-Content-Type-Options': 'nosniff',
},
})
}
export const POST = withRateLimit(handler, {
route: '/api/ai/xiaoyu-reply',
keyPrefix: 'ai',
capacity: 10,
refillPerSecond: 10 / 60,
})

Some files were not shown because too many files have changed in this diff Show More