Compare commits

84 Commits
Author SHA1 Message Date
root ed34144d26 docs: env knobs for observability + pm2-logrotate setup
Deploy / Build and deploy (push) Has been cancelled
2026-05-19 08:55:39 +00:00
root c20e6a9b8e feat(admin): add Logs entry to sidebar 2026-05-19 08:55:21 +00:00
root 428c998537 feat(admin): add /admin/logs page with logs + rate-limit tabs 2026-05-19 08:54:08 +00:00
root 4ea91f9f53 feat(admin): add GET /api/admin/logs for ring buffer + rate_limit_events 2026-05-19 08:50:15 +00:00
root 5c9a51b8fc refactor(observability): replace 20 console.error/warn with structured log.* calls 2026-05-19 08:43:32 +00:00
root d8de292cf0 feat(api): apply per-IP rate limits to friends/messages/posts/uploads/payment-create routes 2026-05-19 08:31:40 +00:00
root 7340153fe0 feat(api): apply per-IP rate limit (10/min) to all 7 AI routes 2026-05-19 05:40:42 +00:00
root 8471bde8b7 fix(api): mark /api/health as force-dynamic to bypass route cache 2026-05-19 05:38:18 +00:00
root c04059d920 feat(api): add GET /api/health with live dependency probes 2026-05-19 05:34:51 +00:00
root 3829714fa2 fix(observability): NaN-safe cache, single-flight, env restore in health tests 2026-05-19 05:29:25 +00:00
rootandClaude Sonnet 4.6 05e24ed486 feat(observability): add /api/health probes (deepseek, supabase, sqlite) with 10s cache
Also update vitest.config to load .env.local via vite loadEnv so probe tests that depend on DEEPSEEK_API_KEY/SUPABASE_URL can properly exercise fetch paths.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-18 19:52:13 +00:00
root 9f78bc7db3 fix(observability): typecheck-clean withRateLimit test + void on analytics 2026-05-18 19:40:27 +00:00
rootandClaude Sonnet 4.6 23092e8273 feat(observability): add withRateLimit handler wrapper
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-18 19:33:10 +00:00
rootandClaude Sonnet 4.6 6e5d789fd3 feat(observability): add rate_limit_events table + record/list helpers
Adds the rate_limit_events table (ts, route, ip, key) to initSchema,
plus recordRateLimitEvent and listRateLimitEvents helpers in analytics.ts.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-18 19:26:44 +00:00
root d23e30e8eb fix(observability): guard clock-backward + log fail-open + fractional refill test 2026-05-18 19:23:22 +00:00
root 813858a7a8 feat(observability): add in-memory token-bucket rate limiter 2026-05-18 19:19:05 +00:00
root c8a6d2b303 fix(observability): defensive copy in log.recent + positive LOG_LEVEL test 2026-05-18 19:16:31 +00:00
rootandClaude Sonnet 4.6 0a093acdda feat(observability): add JSON-line logger with ring buffer for admin viewer
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-18 19:11:42 +00:00
root c6c0708055 feat(observability): add getClientIp with XFF / x-real-ip / cf-connecting-ip 2026-05-18 19:05:40 +00:00
root 2fa74b0db8 feat(observability): add shared types for logger/rate-limit/health 2026-05-18 19:02:04 +00:00
rootandClaude Opus 4.7 cda7892379 docs: add Slice 2 implementation plan (16 tasks)
Maps every section of the stability+observability spec to a TDD
checkpoint: 4 vitest suites, 7 new files, 4 modified files (analytics
schema, AdminChrome nav, env example, deploy docs), 7+10 routes wrapped
with withRateLimit, 20 console calls migrated to structured logger.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-18 18:54:54 +00:00
rootandClaude Opus 4.7 75d45cec43 docs: add Slice 2 spec for stability + observability hardening
Combined slice covering per-IP rate limiting, structured logging, and
real dependency health probes. All-local (no Sentry/Redis) to fit
single-VPS pm2 deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-18 18:38:37 +00:00
root 36e5245299 fix(ai): add timeout + env-driven base/model to deepseek provider
- 15s AbortController timeout on stream() and json() fetch calls
- Read DEEPSEEK_BASE_URL / DEEPSEEK_MODEL / DEEPSEEK_TIMEOUT_MS from env
- Export DeepSeekTimeoutError so callers can distinguish

Spec: docs/superpowers/specs/2026-05-18-deepseek-verify-and-harden-design.md
2026-05-18 13:49:23 +00:00
root 0a8af44fc9 docs: add Slice 1 spec + plan for DeepSeek hardening
Spec: docs/superpowers/specs/2026-05-18-deepseek-verify-and-harden-design.md
Plan: docs/superpowers/plans/2026-05-18-deepseek-verify-and-harden-plan.md

Slice 1 of the MatchU launch roadmap: verify the already-wired DeepSeek
provider works end-to-end and add AbortController timeout + env-driven
base/model to lib/ai/deepseek.ts.
2026-05-18 13:24:24 +00:00
rootandCursor d4cd0cb33d feat: add Gitea deploy workflow for dev branch
Push to dev triggers docker compose build/deploy on label-ac-1 runner.

Co-authored-by: Cursor <[email protected]>
2026-05-16 08:01:44 +00:00
root 7415723b9c first commit 2026-05-16 07:45:56 +00:00
root 1264b643af fix: robust clipboard copy for friend code, larger touch target 2026-05-13 05:03:56 +00:00
root 90d7d46f28 feat: tap to copy friend code on profile page 2026-05-13 04:55:15 +00:00
root bf7e3bd4f2 feat: support #code search in friend search page 2026-05-13 04:40:30 +00:00
root 5cde30f447 feat: add friend 3-grid entry to MessagesView 2026-05-13 04:34:09 +00:00
root 2ccf528cd8 feat: add friend requests page and friends list page 2026-05-13 04:32:53 +00:00
root 5a68eeb447 feat: add friend search page /friends/search 2026-05-13 04:30:56 +00:00
root 3882dffbe7 feat: POST /api/friends/requests/[id]/accept and /reject 2026-05-13 04:29:24 +00:00
root 5498c532a7 feat: GET and POST /api/friends/requests 2026-05-13 04:28:49 +00:00
root dd9626dc6c feat: GET /api/friends/search and GET /api/friends routes 2026-05-13 04:27:17 +00:00
root b2e600bb00 feat: friends-repository DB functions (search, send, accept, reject, list) 2026-05-13 04:24:47 +00:00
root fd84540406 test: getRelationStatus unit tests 2026-05-13 04:23:30 +00:00
root c36f99fc97 feat: friends-repository types and getRelationStatus helper 2026-05-13 04:22:55 +00:00
root 9f24829cc1 feat: add friend_requests and friends tables with RLS 2026-05-13 04:22:33 +00:00
rootandClaude Sonnet 4.6 6a213303ba docs: add AI virtual companion upgrade design spec
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-12 03:34:49 +00:00
root c09952f852 fix: add explicit NavItem/NavSection types to AdminChrome 2026-05-09 11:02:27 +00:00
root df4be7a9dc feat: add site config management module 2026-05-09 10:57:10 +00:00
root d2d2f713d6 feat: add payment config management module 2026-05-09 10:21:04 +00:00
rootandClaude Sonnet 4.6 e89534689a feat: add membership plans management module
Adds API routes and admin UI for managing subscription membership plans with support for creating, editing, and deleting plans. Includes pricing, duration, and features configuration.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-09 10:03:41 +00:00
root e5057aa044 fix: add expires_at field, level validation, and delete error handling to announcements 2026-05-09 09:48:36 +00:00
root 2d165f590f feat: add announcement management module 2026-05-09 09:38:41 +00:00
root dc2c9cd12b feat: add content moderation page 2026-05-09 09:11:32 +00:00
root 8d307d6bae feat: upgrade admin dashboard with stats and SVG charts 2026-05-09 09:04:21 +00:00
root b2c96341aa feat: redesign AdminChrome with sidebar layout 2026-05-09 08:58:11 +00:00
root 98cb855e27 feat: add announcements, membership_plans, site_config tables 2026-05-09 08:51:39 +00:00
rootandClaude Sonnet 4.6 2889519a43 fix: atomic chat usage increment + post-stream quota accounting
- Add increment_chat_usage() Postgres function (SECURITY DEFINER) for
  atomic INSERT ... ON CONFLICT DO UPDATE, eliminating the read-then-write
  race that allowed quota bypass under concurrent requests
- Move incrementChatUsage() call to after the AI stream completes so
  failed AI responses no longer burn daily quota

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 04:50:14 +00:00
rootandClaude Sonnet 4.6 67957f61a7 feat: wire payment UI into MembershipView, LockedProfileCard, SvipCard
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 04:35:03 +00:00
rootandClaude Sonnet 4.6 da4c597b3a feat: update SvipCard to accept onOpenMembership prop
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 04:34:50 +00:00
rootandClaude Sonnet 4.6 a85c6427ba feat: add LockedProfileCard component
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 04:34:30 +00:00
root 4d085cb003 feat: add QRCodeView component with SSE polling 2026-05-07 04:20:20 +00:00
root 99aabd7e10 feat: add useSubscriptionStore (Zustand persist) 2026-05-07 04:18:45 +00:00
root aff2c06791 feat: add VIP gating to profiles and chat quota to xiaoyu-reply 2026-05-07 04:11:43 +00:00
root 98dce5929f fix: parallelize all subscription DB fetches in /api/subscription/me 2026-05-07 04:07:44 +00:00
root 06e3815cfc feat: add GET /api/subscription/me endpoint 2026-05-07 03:56:45 +00:00
root b21e805531 feat: add GET /api/payment/stream SSE endpoint 2026-05-07 03:56:34 +00:00
root 7eeabf7d4d feat: add POST /api/payment/webhook with signature verification 2026-05-07 03:56:21 +00:00
root f8b950c879 feat: add POST /api/payment/create endpoint 2026-05-07 03:56:11 +00:00
root 2e39ac52ef test: add unit tests for xunhupay sign computation 2026-05-07 03:46:48 +00:00
root 292bc1e5cf feat: add subscription DB helpers (isVip, incrementUsage, activateSubscription) 2026-05-07 03:43:51 +00:00
root caa70b8ed8 feat: add xunhupay API client with MD5 signing 2026-05-07 03:40:20 +00:00
root 6269c41ceb fix: add plan check constraint to subscriptions table 2026-05-07 03:35:18 +00:00
root aca0ec9142 feat: add payment schema (orders, subscriptions, chat_daily_usage) 2026-05-07 03:32:43 +00:00
rootandClaude Sonnet 4.6 1404848bfc docs: add payment system implementation plan (12 tasks)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 03:21:57 +00:00
rootandClaude Sonnet 4.6 502eb54515 docs: add payment system design spec (虎皮椒 + SSE)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 03:08:26 +00:00
root ebeddd1b7b feat: hide TabBar on scroll down, show on scroll up 2026-05-06 12:08:40 +00:00
root fa02ec725e feat: wire ZodiacPairingTool into explore tab tool grid 2026-05-06 11:38:10 +00:00
root 392cac3785 feat: add ZodiacPairingTool component (intro/select/loading/result/share) 2026-05-06 11:35:08 +00:00
root 5fe2d969bb feat: add zodiac-pairing AI API route with DeepSeek 2026-05-06 11:24:48 +00:00
root b8c906041f chore: add html2canvas for zodiac share card 2026-05-06 11:22:12 +00:00
root eafe5eca93 docs: add zodiac pairing implementation plan 2026-05-06 11:18:59 +00:00
root ce51e7ee19 docs: add zodiac pairing tool design spec 2026-05-06 11:12:01 +00:00
rootandClaude Sonnet 4.6 cf3f073f3c docs: 心动盲盒交互化设计文档
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-30 16:17:20 +00:00
root 413a9b913b feat: add public persona, post like, avatar upload, and local file serving routes 2026-04-29 03:26:12 +00:00
root c4f5c3a4b6 feat: add admin seed and profile-photo upload routes 2026-04-29 03:24:45 +00:00
root 123a5abe6e feat: add admin users and posts management routes 2026-04-29 03:22:37 +00:00
root 6c9f96f5b8 feat: add admin profiles/personas/scripts CRUD routes 2026-04-29 03:21:49 +00:00
root 8a1f7a667b feat: add admin login/logout routes 2026-04-29 03:19:41 +00:00
root 7eecf80fe6 feat: add auth proxy route for Supabase Auth 2026-04-29 03:19:17 +00:00
root d701adc642 chore: initial commit of existing codebase 2026-04-29 03:17:38 +00:00
317 changed files with 57982 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
# 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
@@ -0,0 +1,30 @@
# 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
@@ -0,0 +1,130 @@
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
@@ -0,0 +1,51 @@
# 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
@@ -0,0 +1,153 @@
<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>
@@ -0,0 +1,363 @@
<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>
@@ -0,0 +1,224 @@
<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>
@@ -0,0 +1,180 @@
<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>
@@ -0,0 +1,145 @@
<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>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1777566863270}
@@ -0,0 +1 @@
1855761
@@ -0,0 +1,87 @@
<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>
@@ -0,0 +1,110 @@
<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>
@@ -0,0 +1,90 @@
<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>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -0,0 +1 @@
{"reason":"owner process exited","timestamp":1778557038236}
@@ -0,0 +1 @@
3561746
@@ -0,0 +1,66 @@
<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>
@@ -0,0 +1,69 @@
<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>
@@ -0,0 +1,130 @@
<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>
@@ -0,0 +1,83 @@
<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>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1778646633094}
@@ -0,0 +1 @@
3885103
+47
View File
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,251 @@
# 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`
+168 -1
View File
@@ -1,2 +1,169 @@
# 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
@@ -0,0 +1,135 @@
# 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
@@ -0,0 +1 @@
如果 VPS 使用本地图片存储,可参考此目录结构;生产推荐 Supabase Storage。
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function TabsLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { WarmUApp } from "@/app/page";
export default function MatchPage() {
return <WarmUApp initialTab="match" showSplashOnStart={false} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { WarmUApp } from "@/app/page";
export default function MePage() {
return <WarmUApp initialTab="profile" showSplashOnStart={false} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { WarmUApp } from "@/app/page";
export default function MessagesPage() {
return <WarmUApp initialTab="messages" showSplashOnStart={false} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { WarmUApp } from "@/app/page";
export default function ToolsPage() {
return <WarmUApp initialTab="explore" showSplashOnStart={false} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { WarmUApp } from "@/app/page";
export default function VirtualPage() {
return <WarmUApp initialTab="virtual" showSplashOnStart={false} />;
}
+490
View File
@@ -0,0 +1,490 @@
'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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,13 @@
'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
@@ -0,0 +1,22 @@
'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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,352 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,5 @@
import type { ReactNode } from 'react'
export default function AdminLayout({ children }: { children: ReactNode }) {
return children
}
+18
View File
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,96 @@
'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
@@ -0,0 +1,293 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,190 @@
'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
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,34 @@
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 })
}
@@ -0,0 +1,46 @@
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
@@ -0,0 +1,49 @@
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
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,56 @@
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
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,53 @@
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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,30 @@
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)
}
}
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,78 @@
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
@@ -0,0 +1,86 @@
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
@@ -0,0 +1,75 @@
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
@@ -0,0 +1,127 @@
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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,103 @@
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