feat: 完成阶段 2 - 用户系统与智能体初始化
## 完成的功能 - 用户登录/注册(邮箱 + Google OAuth) - 初始问题引导流程(4 个问题) - 智能体自动生成(根据用户回答) - 智能体列表页面 ## 新增文件 - 登录/注册页面和组件 - Onboarding 页面和组件 - 智能体列表页面 - 智能体 API 端点 - 智能体 Prompt 设计 ## 数据库迁移 - onboarding_fix 迁移(users.has_completed_onboarding) ## 测试 - 登录/注册:100% 通过 - Onboarding:85% 通过 - 智能体功能:85% 通过 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8555cdf0c9
commit
4c42d6b6f5
36
README.md
Normal file
36
README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
67
docs/VERSION.md
Normal file
67
docs/VERSION.md
Normal file
@ -0,0 +1,67 @@
|
||||
# 版本文档
|
||||
|
||||
## 阶段 2 完成:用户系统与智能体初始化
|
||||
|
||||
**完成日期:** 2026-01-12
|
||||
|
||||
### 新增功能
|
||||
|
||||
- 用户登录/注册(邮箱 + Google OAuth)
|
||||
- 初始问题引导(4 个问题)
|
||||
- 智能体自动生成
|
||||
- 智能体列表页面
|
||||
|
||||
### 新增文件
|
||||
|
||||
- 登录/注册页面和组件
|
||||
- Onboarding 页面和组件
|
||||
- 智能体列表页面
|
||||
- 智能体 API 端点
|
||||
- 智能体 Prompt 设计
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
- `onboarding_fix` 迁移(users.has_completed_onboarding)
|
||||
|
||||
### 测试结果
|
||||
|
||||
| 模块 | 通过率 |
|
||||
|------|--------|
|
||||
| 登录/注册 | 100% |
|
||||
| Onboarding | 85% |
|
||||
| 智能体功能 | 85% |
|
||||
|
||||
### 问题修复
|
||||
|
||||
- Supabase 客户端拆分(client/server)
|
||||
- @hookform/resolvers 依赖安装
|
||||
- 国际化字典获取修复
|
||||
|
||||
### 代码审核
|
||||
|
||||
- code-review-login.md
|
||||
- code-review-onboarding.md
|
||||
- code-review-agents-api.md
|
||||
|
||||
---
|
||||
|
||||
## 阶段 1 完成:基础项目搭建
|
||||
|
||||
**完成日期:** 2026-01-12
|
||||
|
||||
### 完成内容
|
||||
|
||||
- Next.js 14 项目初始化
|
||||
- Tailwind CSS 配置
|
||||
- Supabase 集成
|
||||
- shadcn/ui 组件库集成
|
||||
- 国际化 (i18n) 配置
|
||||
- ESLint 和 Prettier 配置
|
||||
- 项目结构规划
|
||||
|
||||
### 新增文件
|
||||
|
||||
- 项目配置文件 (next.config.ts, tsconfig.json, eslint.config.mjs 等)
|
||||
- 基础布局组件
|
||||
- 国际化配置
|
||||
- Supabase 配置
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6844
package-lock.json
generated
Normal file
6844
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "echo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"playwright": "^1.57.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
191
src/app/[locale]/agents/AgentsPageClient.tsx
Normal file
191
src/app/[locale]/agents/AgentsPageClient.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, X, Sparkles, Loader2 } from 'lucide-react';
|
||||
import { AgentList } from '@/components/agents/AgentList';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
|
||||
import type { Dictionary } from '@/get-dictionary';
|
||||
|
||||
interface AgentsPageClientProps {
|
||||
locale: string;
|
||||
isPremium: boolean;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export function AgentsPageClient({ locale, isPremium, dict }: AgentsPageClientProps) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
personality: '',
|
||||
background: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreateAgent = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
setError('请输入智能体名称');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/agents', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
background: formData.background,
|
||||
personality: {
|
||||
traits: formData.personality.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
tone: 'warm',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || '创建失败');
|
||||
}
|
||||
|
||||
// Close modal and refresh
|
||||
setShowCreateModal(false);
|
||||
setFormData({ name: '', personality: '', background: '' });
|
||||
// The AgentList will auto-refresh
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AgentList
|
||||
locale={locale}
|
||||
isPremium={isPremium}
|
||||
onCreateAgent={() => setShowCreateModal(true)}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
创建自定义智能体
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
为你的新智能体起个名字吧
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal body */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Name input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
智能体名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如:十年后的自己"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Background textarea */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
背景故事
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.background}
|
||||
onChange={(e) => setFormData({ ...formData, background: e.target.value })}
|
||||
placeholder="描述这个智能体的背景故事..."
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Personality input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
性格特点
|
||||
</label>
|
||||
<Input
|
||||
value={formData.personality}
|
||||
onChange={(e) => setFormData({ ...formData, personality: e.target.value })}
|
||||
placeholder="例如:温柔、乐观、善解人意(用逗号分隔)"
|
||||
maxLength={100}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
多个特点请用逗号分隔
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Premium notice */}
|
||||
{!isPremium && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>注意:</strong>
|
||||
免费用户最多只能拥有 1 个自定义智能体。
|
||||
升级到高级版可创建最多 3 个智能体。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateAgent}
|
||||
isLoading={creating}
|
||||
disabled={!formData.name.trim() || creating}
|
||||
>
|
||||
{creating ? '创建中...' : '创建智能体'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
src/app/[locale]/agents/page.tsx
Normal file
104
src/app/[locale]/agents/page.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getDictionary } from '@/get-dictionary';
|
||||
import { createServerSupabaseClient } from '@/lib/supabase/server';
|
||||
import type { Locale } from '@/i18n-config';
|
||||
import { AgentsPageClient } from './AgentsPageClient';
|
||||
|
||||
export default async function AgentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dict = await getDictionary(locale);
|
||||
|
||||
// Check authentication
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
|
||||
if (error || !user) {
|
||||
redirect(`/${locale}/login`);
|
||||
}
|
||||
|
||||
// Get user subscription status
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('subscription_status')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const isPremium = userData?.subscription_status === 'premium';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-gray-100">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
|
||||
{dict.common.appName}
|
||||
</h1>
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<a
|
||||
href={`/${locale}`}
|
||||
className="text-gray-600 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
{dict.nav.home}
|
||||
</a>
|
||||
<a
|
||||
href={`/${locale}/letters`}
|
||||
className="text-gray-600 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
{dict.nav.letters}
|
||||
</a>
|
||||
<span className="text-purple-600 font-medium">{dict.nav.agents}</span>
|
||||
<a
|
||||
href={`/${locale}/stamps`}
|
||||
className="text-gray-600 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
{dict.nav.stamps}
|
||||
</a>
|
||||
</nav>
|
||||
<div className="flex items-center gap-4">
|
||||
{user.user_metadata?.avatar_url ? (
|
||||
<img
|
||||
src={user.user_metadata.avatar_url}
|
||||
alt="Avatar"
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-purple-600">
|
||||
{user.email?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Page title */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{dict.agent.title}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
{dict.onboarding.completeSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Client component with all the interactive logic */}
|
||||
<AgentsPageClient
|
||||
locale={locale}
|
||||
isPremium={isPremium}
|
||||
dict={dict}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/app/[locale]/layout.tsx
Normal file
34
src/app/[locale]/layout.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
28
src/app/[locale]/login/page.tsx
Normal file
28
src/app/[locale]/login/page.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { getDictionary } from '@/get-dictionary';
|
||||
import { LoginForm } from '@/components/auth/LoginForm';
|
||||
import { i18n } from '@/i18n-config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return i18n.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
interface LoginPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: LoginPageProps) {
|
||||
const { locale } = await params;
|
||||
const dictionary = await getDictionary(locale);
|
||||
|
||||
return {
|
||||
title: `${dictionary.auth.signIn} - ${dictionary.common.appName}`,
|
||||
description: dictionary.auth.subtitle,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LoginPage({ params }: LoginPageProps) {
|
||||
const { locale } = await params;
|
||||
const dictionary = await getDictionary(locale);
|
||||
|
||||
return <LoginForm dictionary={dictionary.auth} />;
|
||||
}
|
||||
162
src/app/[locale]/onboarding/complete/page.tsx
Normal file
162
src/app/[locale]/onboarding/complete/page.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CheckCircle, Sparkles, ArrowRight, Heart } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { Locale } from '@/i18n-config';
|
||||
|
||||
interface Dictionary {
|
||||
common: Record<string, string>;
|
||||
nav: Record<string, string>;
|
||||
auth: Record<string, string>;
|
||||
onboarding: Record<string, string>;
|
||||
letter: Record<string, string>;
|
||||
agent: Record<string, string>;
|
||||
stamps: Record<string, string>;
|
||||
growth: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CompletePageProps {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}
|
||||
|
||||
function CelebrationAnimation() {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute animate-float"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 3}s`,
|
||||
animationDuration: `${3 + Math.random() * 2}s`,
|
||||
}}
|
||||
>
|
||||
<Sparkles
|
||||
className="text-pink-400 opacity-60"
|
||||
style={{
|
||||
fontSize: `${Math.random() * 20 + 10}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompletePage({ params }: CompletePageProps) {
|
||||
const router = useRouter();
|
||||
const [dict, setDict] = useState<Dictionary | null>(null);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
const [locale, setLocale] = useState<string>('zh-CN');
|
||||
|
||||
// Unwrap params in a compatible way for Next.js 15
|
||||
useEffect(() => {
|
||||
async function loadDictionary() {
|
||||
const resolvedParams = await params;
|
||||
const loc = 'locale' in resolvedParams ? resolvedParams.locale : (resolvedParams as { locale: Locale }).locale;
|
||||
setLocale(loc);
|
||||
try {
|
||||
const response = await fetch(`/api/dictionary?locale=${loc}`);
|
||||
if (response.ok) {
|
||||
const dictionary = await response.json();
|
||||
setDict(dictionary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dictionary:', error);
|
||||
}
|
||||
}
|
||||
loadDictionary();
|
||||
}, [params]);
|
||||
|
||||
// Auto-redirect to home after 5 seconds
|
||||
useEffect(() => {
|
||||
if (!dict) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsRedirecting(true);
|
||||
router.push(`/${locale}`);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [router, locale, dict]);
|
||||
|
||||
if (!dict) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white flex items-center justify-center">
|
||||
<div className="animate-pulse text-purple-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white relative overflow-hidden">
|
||||
<CelebrationAnimation />
|
||||
|
||||
{/* Header */}
|
||||
<header className="relative z-10 container mx-auto px-4 py-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
|
||||
{dict.common.appName}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="relative z-10 container mx-auto px-4 py-12 md:py-20">
|
||||
<div className="max-w-xl mx-auto text-center">
|
||||
{/* Success icon */}
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-green-100 mb-4 animate-bounce-gentle">
|
||||
<CheckCircle className="w-12 h-12 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
{dict.onboarding.completeTitle}
|
||||
</h2>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-xl text-gray-600 mb-8 leading-relaxed">
|
||||
{dict.onboarding.completeSubtitle}
|
||||
</p>
|
||||
|
||||
{/* Encouragement */}
|
||||
<div className="bg-white rounded-xl p-6 mb-8 shadow-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Heart className="w-5 h-5 text-pink-500" />
|
||||
<span className="text-gray-700 font-medium">{dict.onboarding.encouragement}</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{dict.onboarding.encouragementDetail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href={`/${locale}`}>
|
||||
<Button size="lg" className="w-full sm:w-auto">
|
||||
{dict.onboarding.goHome}
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Redirect message */}
|
||||
{isRedirecting && (
|
||||
<p className="mt-6 text-gray-400 text-sm animate-pulse">
|
||||
{dict.onboarding.redirecting}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 container mx-auto px-4 py-8 text-center text-gray-500 text-sm">
|
||||
<p>{dict.onboarding.footerNote}</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/app/[locale]/onboarding/page.tsx
Normal file
133
src/app/[locale]/onboarding/page.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { Locale } from '@/i18n-config';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Dictionary {
|
||||
common: Record<string, string>;
|
||||
nav: Record<string, string>;
|
||||
auth: Record<string, string>;
|
||||
onboarding: Record<string, string>;
|
||||
letter: Record<string, string>;
|
||||
agent: Record<string, string>;
|
||||
stamps: Record<string, string>;
|
||||
growth: Record<string, string>;
|
||||
}
|
||||
|
||||
interface OnboardingPageProps {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}
|
||||
|
||||
export default function OnboardingPage({ params }: OnboardingPageProps) {
|
||||
const [dict, setDict] = useState<Dictionary | null>(null);
|
||||
const [locale, setLocale] = useState<string>('zh-CN');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDictionary() {
|
||||
const { locale: loc } = await params;
|
||||
setLocale(loc);
|
||||
try {
|
||||
const response = await fetch(`/api/dictionary?locale=${loc}`);
|
||||
if (response.ok) {
|
||||
const dictionary = await response.json();
|
||||
setDict(dictionary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dictionary:', error);
|
||||
}
|
||||
}
|
||||
loadDictionary();
|
||||
}, [params]);
|
||||
|
||||
if (!dict) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white flex items-center justify-center">
|
||||
<div className="animate-pulse text-purple-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white">
|
||||
{/* Header */}
|
||||
<header className="container mx-auto px-4 py-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
|
||||
{dict.common.appName}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-12 md:py-20">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
{/* Animated welcome badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-pink-100 text-pink-600 rounded-full mb-8 animate-fade-in-up">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{dict.onboarding.welcomeBadge}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{dict.onboarding.title}
|
||||
<span className="block bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
|
||||
{dict.onboarding.subtitle}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xl text-gray-600 mb-10 max-w-xl mx-auto leading-relaxed">
|
||||
{dict.onboarding.description}
|
||||
</p>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-12">
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center">
|
||||
<span className="text-2xl">1</span>
|
||||
</div>
|
||||
<p className="text-gray-700 font-medium">{dict.onboarding.step1}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<span className="text-2xl">2</span>
|
||||
</div>
|
||||
<p className="text-gray-700 font-medium">{dict.onboarding.step2}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<span className="text-2xl">3</span>
|
||||
</div>
|
||||
<p className="text-gray-700 font-medium">{dict.onboarding.step3}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Link href={`/${locale}/onboarding/questions`}>
|
||||
<Button size="lg" className="px-8 py-4 text-lg">
|
||||
{dict.onboarding.startButton}
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Skip link */}
|
||||
<p className="mt-6 text-gray-500 text-sm">
|
||||
{dict.onboarding.skipIntro}{' '}
|
||||
<Link
|
||||
href={`/${locale}/onboarding/questions`}
|
||||
className="text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
{dict.onboarding.skipLink}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="fixed top-20 left-10 w-32 h-32 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob" />
|
||||
<div className="fixed top-40 right-10 w-32 h-32 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-2000" />
|
||||
<div className="fixed -bottom-8 left-1/2 w-32 h-32 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-4000" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/app/[locale]/onboarding/questions/page.tsx
Normal file
48
src/app/[locale]/onboarding/questions/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Questionnaire } from '@/components/onboarding';
|
||||
import type { Locale } from '@/i18n-config';
|
||||
|
||||
interface Dictionary {
|
||||
onboarding: Record<string, string>;
|
||||
}
|
||||
|
||||
interface QuestionsPageProps {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}
|
||||
|
||||
export default function QuestionsPage({ params }: QuestionsPageProps) {
|
||||
const [dict, setDict] = useState<Dictionary | null>(null);
|
||||
const [locale, setLocale] = useState<string>('zh-CN');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDictionary() {
|
||||
const { locale: loc } = await params;
|
||||
setLocale(loc);
|
||||
try {
|
||||
const response = await fetch(`/api/dictionary?locale=${loc}`);
|
||||
if (response.ok) {
|
||||
const dictionary = await response.json();
|
||||
setDict(dictionary);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dictionary:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadDictionary();
|
||||
}, [params]);
|
||||
|
||||
if (loading || !dict) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white flex items-center justify-center">
|
||||
<div className="animate-pulse text-purple-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Questionnaire dict={dict} locale={locale as Locale} />;
|
||||
}
|
||||
123
src/app/[locale]/page.tsx
Normal file
123
src/app/[locale]/page.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Heart, Mail, TreeDeciduous } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { getDictionary } from "@/get-dictionary";
|
||||
import type { Locale } from "@/i18n-config";
|
||||
|
||||
export default async function HomePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dict = await getDictionary(locale);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white">
|
||||
{/* Header */}
|
||||
<header className="container mx-auto px-4 py-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
|
||||
{dict.common.appName}
|
||||
</h1>
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link href={`/${locale}/auth`} className="text-gray-600 hover:text-purple-600 transition-colors">
|
||||
{dict.auth.signIn}
|
||||
</Link>
|
||||
<Link href={`/${locale}/auth`} className="text-purple-600 hover:text-purple-700 font-medium">
|
||||
{dict.auth.signUp}
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<main className="container mx-auto px-4 py-20">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-5xl md:text-6xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
给未来的自己
|
||||
<span className="block bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
|
||||
写一封信
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-10 max-w-2xl mx-auto">
|
||||
在这里,你可以向未来的自己倾诉当下的心情与期望。
|
||||
AI 智能体将以「未来的你」的身份,在 6-12 小时后给你温暖的回复。
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href={`/${locale}/auth`}>
|
||||
<Button size="lg" className="w-full sm:w-auto">
|
||||
开始写信
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${locale}/about`}>
|
||||
<Button variant="outline" size="lg" className="w-full sm:w-auto">
|
||||
了解更多
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="grid md:grid-cols-3 gap-8 mt-24">
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center">
|
||||
<Mail className="w-8 h-8 text-pink-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">写信给未来</h3>
|
||||
<p className="text-gray-600">
|
||||
用文字记录当下的心情、困惑与期望,让未来的自己与你对话
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<Heart className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">AI 智能回复</h3>
|
||||
<p className="text-gray-600">
|
||||
根据你的描述,AI 将以「未来的你」给予温暖、治愈的回应
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<TreeDeciduous className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">成长树</h3>
|
||||
<p className="text-gray-600">
|
||||
追踪你的写信历程,见证自己的成长与改变
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-24 text-center">
|
||||
<div className="bg-gradient-to-r from-pink-500 to-purple-600 rounded-2xl p-8 md:p-12 text-white">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
||||
准备好了吗?
|
||||
</h3>
|
||||
<p className="text-lg opacity-90 mb-6">
|
||||
现在就开始与未来的自己建立连接
|
||||
</p>
|
||||
<Link href={`/${locale}/auth`}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="bg-white text-purple-600 hover:bg-gray-100"
|
||||
>
|
||||
立即开始
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="container mx-auto px-4 py-8 text-center text-gray-500 text-sm">
|
||||
<p>© 2024 {dict.common.appName}. 保留所有权利。</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/[locale]/register/page.tsx
Normal file
28
src/app/[locale]/register/page.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { getDictionary } from '@/get-dictionary';
|
||||
import { RegisterForm } from '@/components/auth/RegisterForm';
|
||||
import { i18n } from '@/i18n-config';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return i18n.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
interface RegisterPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: RegisterPageProps) {
|
||||
const { locale } = await params;
|
||||
const dictionary = await getDictionary(locale);
|
||||
|
||||
return {
|
||||
title: `${dictionary.auth.signUp} - ${dictionary.common.appName}`,
|
||||
description: dictionary.auth.signUpSubtitle,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RegisterPage({ params }: RegisterPageProps) {
|
||||
const { locale } = await params;
|
||||
const dictionary = await getDictionary(locale);
|
||||
|
||||
return <RegisterForm dictionary={dictionary.auth} />;
|
||||
}
|
||||
221
src/app/api/agents/generate/route.ts
Normal file
221
src/app/api/agents/generate/route.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createServerSupabaseClient, createServiceRoleClient } from '@/lib/supabase/server';
|
||||
import { generateAgentSystemPrompt, OnboardingAnswers } from '@/lib/prompts/agent';
|
||||
import type { Agent, OnboardingAnswers as OnboardingAnswersType } from '@/types';
|
||||
|
||||
/**
|
||||
* POST /api/agents/generate
|
||||
* 生成默认智能体「十年后的自己」
|
||||
*
|
||||
* 在用户完成初始问题(onboarding)后调用,
|
||||
* 根据用户的回答生成个性化的智能体
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// 1. 验证用户身份
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
console.error('Auth error:', authError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 获取用户的初始问题回答
|
||||
const { data: onboardingData, error: onboardingError } = await supabase
|
||||
.from('onboarding_answers')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (onboardingError || !onboardingData) {
|
||||
console.error('Error fetching onboarding answers:', onboardingError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Onboarding answers not found. Please complete onboarding first.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 检查用户是否已有默认智能体
|
||||
const { data: existingAgents, error: checkError } = await supabase
|
||||
.from('agents')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_default', true)
|
||||
.limit(1);
|
||||
|
||||
if (checkError) {
|
||||
console.error('Error checking existing agents:', checkError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check existing agents' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existingAgents && existingAgents.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Default agent already exists', agent_id: existingAgents[0].id },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 构建 OnboardingAnswers 对象
|
||||
const answers: OnboardingAnswers = {
|
||||
future_self: onboardingData.question_1,
|
||||
future_location: onboardingData.question_2,
|
||||
current_worry: onboardingData.question_3,
|
||||
important_person: onboardingData.question_4,
|
||||
};
|
||||
|
||||
// 5. 获取用户信息用于生成系统提示词
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('language, subscription_status')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
// 6. 生成系统提示词
|
||||
const systemPrompt = generateAgentSystemPrompt(answers, {
|
||||
userName: user.email?.split('@')[0] || '我',
|
||||
agentName: '十年后的自己',
|
||||
letterWritingDate: new Date(onboardingData.created_at),
|
||||
currentDate: new Date(),
|
||||
language: userData?.language || 'zh-CN',
|
||||
personalityType: 'warm', // 默认使用温暖类型
|
||||
isPremium: userData?.subscription_status === 'premium',
|
||||
});
|
||||
|
||||
// 7. 使用 Service Role 客户端创建智能体记录
|
||||
const serviceRoleClient = createServiceRoleClient();
|
||||
if (!serviceRoleClient) {
|
||||
console.error('Service Role client not available');
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 8. 创建智能体记录
|
||||
const { data: agent, error: insertError } = await serviceRoleClient
|
||||
.from('agents')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: '十年后的自己',
|
||||
personality: {
|
||||
traits: ['warm', 'wise', 'patient'],
|
||||
tone: 'gentle',
|
||||
communication_style: 'reflective',
|
||||
response_length: 'medium',
|
||||
emoji_usage: 'rare',
|
||||
formality: 'casual',
|
||||
},
|
||||
background: generateAgentBackground(answers, {
|
||||
letterWritingDate: new Date(onboardingData.created_at),
|
||||
}),
|
||||
system_prompt: systemPrompt,
|
||||
is_custom: false,
|
||||
is_default: true,
|
||||
language: userData?.language || 'zh-CN',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error creating agent:', insertError);
|
||||
return NextResponse.json(
|
||||
{ error: insertError.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 9. 更新用户 onboarding 状态(如果尚未更新)
|
||||
const { error: updateError } = await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
has_completed_onboarding: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating user onboarding status:', updateError);
|
||||
// 不应该失败,因为 agent 已经创建成功
|
||||
}
|
||||
|
||||
console.log(`Agent generated successfully for user ${user.id}:`, agent.id);
|
||||
|
||||
return NextResponse.json({
|
||||
data: agent,
|
||||
message: 'Default agent generated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in agent generation:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成智能体背景故事(内部函数)
|
||||
* 从 prompts/agent.ts 的逻辑中提取
|
||||
*/
|
||||
function generateAgentBackground(
|
||||
answers: OnboardingAnswers,
|
||||
options?: { letterWritingDate?: Date }
|
||||
): string {
|
||||
const { future_self, future_location, current_worry, important_person } = answers;
|
||||
const letterDate = options?.letterWritingDate || new Date();
|
||||
|
||||
// 简单的主题检测
|
||||
const theme = detectTheme(current_worry);
|
||||
const insights: Record<string, string[]> = {
|
||||
career: ['路是一步一步走出来的,不是想出来的', '真正的成功不是达到某个终点'],
|
||||
relationship: ['关系需要经营,但也要给自己空间', '爱是给予,也是接受'],
|
||||
growth: ['所有的经历都是成长的养分', '你比你想象的更强大'],
|
||||
health: ['身体是革命的本钱,善待自己'],
|
||||
finance: ['金钱是工具,不是目的', '真正的财富是内心的平静'],
|
||||
default: ['时间会给你答案', '所有的坚持都会有意义'],
|
||||
};
|
||||
|
||||
const themeInsights = insights[theme] || insights.default;
|
||||
const keyInsight = themeInsights[Math.floor(Math.random() * themeInsights.length)];
|
||||
|
||||
return `
|
||||
你生活在${future_location},已经实现了${future_self}的梦想。
|
||||
|
||||
回想起${letterDate.toLocaleDateString('zh-CN')}的你,你深知那时的你正在经历「${current_worry}」的困扰,
|
||||
同时也非常在乎${important_person}。
|
||||
|
||||
现在的你已经走过了那段旅程,虽然具体的细节你不会透露,
|
||||
但你记得那些挣扎,也记得最终是如何走出来的。
|
||||
|
||||
你学会了一件事:${keyInsight}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测主题
|
||||
*/
|
||||
function detectTheme(worry: string): string {
|
||||
const worryLower = worry.toLowerCase();
|
||||
const themeKeywords: Record<string, string[]> = {
|
||||
career: ['工作', '职业', 'job', 'career', '事业', '升职', '加薪', '面试'],
|
||||
relationship: ['感情', '恋爱', '关系', 'relationship', '爱情', '婚姻', '家庭'],
|
||||
growth: ['成长', '迷茫', '焦虑', '困惑', '压力', 'anxiety', 'confused'],
|
||||
health: ['健康', '身体', 'health', '失眠', '疲惫', '运动'],
|
||||
finance: ['钱', '金钱', '财务', 'finance', 'money', '贷款', '债务'],
|
||||
};
|
||||
|
||||
for (const [theme, keywords] of Object.entries(themeKeywords)) {
|
||||
if (keywords.some(keyword => worryLower.includes(keyword.toLowerCase()))) {
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
219
src/app/api/agents/route.ts
Normal file
219
src/app/api/agents/route.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createServerSupabaseClient } from '@/lib/supabase/server';
|
||||
import type { Agent } from '@/types';
|
||||
|
||||
/**
|
||||
* GET /api/agents
|
||||
* 获取用户的智能体列表
|
||||
*
|
||||
* 返回用户创建的所有智能体,包括默认智能体和自定义智能体
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// 1. 验证用户身份
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
console.error('Auth error:', authError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 获取查询参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeDefault = searchParams.get('include_default') !== 'false'; // 默认包含
|
||||
const isCustom = searchParams.get('is_custom');
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10);
|
||||
|
||||
// 3. 构建查询
|
||||
let query = supabase
|
||||
.from('agents')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
personality,
|
||||
background,
|
||||
avatar_url,
|
||||
is_custom,
|
||||
is_default,
|
||||
language,
|
||||
system_prompt,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
// 4. 过滤条件
|
||||
if (!includeDefault) {
|
||||
query = query.eq('is_custom', true);
|
||||
}
|
||||
|
||||
if (isCustom !== null && isCustom !== undefined) {
|
||||
query = query.eq('is_custom', isCustom === 'true');
|
||||
}
|
||||
|
||||
// 5. 排序和分页
|
||||
const { data: agents, error: fetchError } = await query
|
||||
.order('is_default', { ascending: false }) // 默认智能体优先
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Error fetching agents:', fetchError);
|
||||
return NextResponse.json(
|
||||
{ error: fetchError.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 获取总数
|
||||
const { count: total, error: countError } = await supabase
|
||||
.from('agents')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (countError) {
|
||||
console.error('Error counting agents:', countError);
|
||||
}
|
||||
|
||||
// 7. 获取默认智能体信息(如果没有)
|
||||
let defaultAgent = null;
|
||||
if (includeDefault) {
|
||||
const { data: defaultAgentData } = await supabase
|
||||
.from('agents')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
personality,
|
||||
background,
|
||||
avatar_url,
|
||||
is_custom,
|
||||
is_default,
|
||||
language,
|
||||
system_prompt,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
defaultAgent = defaultAgentData;
|
||||
}
|
||||
|
||||
// 8. 构建响应
|
||||
const response = {
|
||||
data: agents || [],
|
||||
default_agent: defaultAgent,
|
||||
pagination: {
|
||||
total: total || 0,
|
||||
limit,
|
||||
offset,
|
||||
has_more: agents ? agents.length === limit : false,
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
data: response,
|
||||
message: 'Agents retrieved successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/agents:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/agents
|
||||
* 创建自定义智能体(可选功能)
|
||||
*
|
||||
* 用户可以创建自定义的智能体
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// 1. 验证用户身份
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
console.error('Auth error:', authError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, personality, background, avatar_url, language, system_prompt } = body;
|
||||
|
||||
// 2. 验证必填字段
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Agent name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 检查用户是否已有默认智能体
|
||||
const { data: existingDefault } = await supabase
|
||||
.from('agents')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_default', true)
|
||||
.limit(1);
|
||||
|
||||
if (!existingDefault || existingDefault.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Please complete onboarding first to generate your default agent' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 创建自定义智能体
|
||||
const { data: agent, error: insertError } = await supabase
|
||||
.from('agents')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: name,
|
||||
personality: personality || {},
|
||||
background: background || null,
|
||||
avatar_url: avatar_url || null,
|
||||
language: language || 'zh-CN',
|
||||
system_prompt: system_prompt || null,
|
||||
is_custom: true,
|
||||
is_default: false,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error creating custom agent:', insertError);
|
||||
return NextResponse.json(
|
||||
{ error: insertError.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Custom agent created for user ${user.id}:`, agent.id);
|
||||
|
||||
return NextResponse.json({
|
||||
data: agent,
|
||||
message: 'Custom agent created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/agents:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
463
src/app/api/cron/check-milestones/route.ts
Normal file
463
src/app/api/cron/check-milestones/route.ts
Normal file
@ -0,0 +1,463 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// Vercel Cron 请求验证 header
|
||||
const CRON_SECRET = process.env.CRON_SECRET || 'vercel-cron-secret';
|
||||
|
||||
/**
|
||||
* 验证请求是否来自 Vercel Cron
|
||||
*/
|
||||
function verifyCronRequest(request: NextRequest): boolean {
|
||||
const cronKey = request.headers.get('x-vercel-cron');
|
||||
return cronKey === CRON_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Supabase Service Role 客户端
|
||||
*/
|
||||
function createServiceClient() {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
return createClient(supabaseUrl, serviceRoleKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录日志
|
||||
*/
|
||||
function log(level: string, message: string, data?: Record<string, unknown>) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(JSON.stringify({ timestamp, level, message, ...data }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有活跃里程碑定义
|
||||
*/
|
||||
async function getActiveMilestones(
|
||||
supabase: ReturnType<typeof createServiceClient>
|
||||
) {
|
||||
const { data, error } = await supabase
|
||||
.from('milestones')
|
||||
.select(`
|
||||
*,
|
||||
reward_stamp:stamp_definitions(id, code, name, image_url)
|
||||
`)
|
||||
.eq('is_active', true)
|
||||
.order('condition_value', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
log('error', 'Failed to fetch milestones', { error: error.message });
|
||||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户已获得的成就
|
||||
*/
|
||||
async function getUserAchievements(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string
|
||||
): Promise<Set<string>> {
|
||||
const { data, error } = await supabase
|
||||
.from('achievements')
|
||||
.select('milestone_id')
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
log('warn', 'Failed to fetch user achievements', { userId, error: error.message });
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(data.map((a: { milestone_id: string }) => a.milestone_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查里程碑条件:注册
|
||||
*/
|
||||
async function checkRegistrationMilestone(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
// 注册即达成,只需检查用户是否存在
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查里程碑条件:信件数量
|
||||
*/
|
||||
async function checkLetterCountMilestone(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string,
|
||||
targetCount: number
|
||||
): Promise<boolean> {
|
||||
const { count, error } = await supabase
|
||||
.from('letters')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'replied');
|
||||
|
||||
if (error) {
|
||||
log('warn', 'Failed to count letters', { userId, error: error.message });
|
||||
return false;
|
||||
}
|
||||
|
||||
return (count || 0) >= targetCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查里程碑条件:连续活跃天数
|
||||
*/
|
||||
async function checkConsecutiveDaysMilestone(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string,
|
||||
targetDays: number
|
||||
): Promise<boolean> {
|
||||
// 获取用户最近的活跃记录
|
||||
const { data: stats, error } = await supabase
|
||||
.from('daily_stats')
|
||||
.select('stat_date, last_active_at')
|
||||
.eq('user_id', userId)
|
||||
.order('stat_date', { ascending: false })
|
||||
.limit(targetDays + 1);
|
||||
|
||||
if (error || !stats || stats.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否连续
|
||||
let consecutiveDays = 0;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
const statDate = stats[i].stat_date;
|
||||
const expectedDate = new Date(today);
|
||||
expectedDate.setDate(expectedDate.getDate() - i);
|
||||
const expectedDateStr = expectedDate.toISOString().split('T')[0];
|
||||
|
||||
if (statDate === expectedDateStr) {
|
||||
consecutiveDays++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return consecutiveDays >= targetDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查里程碑条件:邮票收集数量
|
||||
*/
|
||||
async function checkStampsCountMilestone(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string,
|
||||
targetCount: number
|
||||
): Promise<boolean> {
|
||||
const { count, error } = await supabase
|
||||
.from('user_stamps')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
log('warn', 'Failed to count stamps', { userId, error: error.message });
|
||||
return false;
|
||||
}
|
||||
|
||||
return (count || 0) >= targetCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个用户的所有里程碑
|
||||
*/
|
||||
async function checkUserMilestones(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string,
|
||||
milestones: Awaited<ReturnType<typeof getActiveMilestones>>,
|
||||
achievedMilestoneIds: Set<string>
|
||||
): Promise<string[]> {
|
||||
const newAchievements: string[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
// 跳过已完成的里程碑
|
||||
if (achievedMilestoneIds.has(milestone.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let conditionMet = false;
|
||||
|
||||
switch (milestone.condition_type) {
|
||||
case 'registration':
|
||||
conditionMet = await checkRegistrationMilestone(supabase, userId);
|
||||
break;
|
||||
|
||||
case 'letter_count':
|
||||
conditionMet = await checkLetterCountMilestone(
|
||||
supabase,
|
||||
userId,
|
||||
milestone.condition_value
|
||||
);
|
||||
break;
|
||||
|
||||
case 'consecutive_days':
|
||||
conditionMet = await checkConsecutiveDaysMilestone(
|
||||
supabase,
|
||||
userId,
|
||||
milestone.condition_value
|
||||
);
|
||||
break;
|
||||
|
||||
case 'stamps_count':
|
||||
conditionMet = await checkStampsCountMilestone(
|
||||
supabase,
|
||||
userId,
|
||||
milestone.condition_value
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
log('warn', 'Unknown milestone condition type', {
|
||||
milestoneId: milestone.id,
|
||||
conditionType: milestone.condition_type
|
||||
});
|
||||
}
|
||||
|
||||
if (conditionMet) {
|
||||
await grantMilestone(supabase, userId, milestone);
|
||||
newAchievements.push(milestone.id);
|
||||
}
|
||||
}
|
||||
|
||||
return newAchievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 授予用户里程碑成就
|
||||
*/
|
||||
async function grantMilestone(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string,
|
||||
milestone: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
reward_stamp: { id: string } | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. 创建成就记录
|
||||
const { error: achievementError } = await supabase
|
||||
.from('achievements')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
milestone_id: milestone.id,
|
||||
achieved_at: new Date().toISOString(),
|
||||
notification_sent: false
|
||||
});
|
||||
|
||||
if (achievementError) {
|
||||
// 如果是重复插入(并发问题),忽略
|
||||
if (achievementError.code !== '23505') {
|
||||
throw achievementError;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log('info', 'Milestone achievement granted', {
|
||||
userId,
|
||||
milestoneId: milestone.id,
|
||||
milestoneName: milestone.name
|
||||
});
|
||||
|
||||
// 2. 如果有奖励邮票,发放给用户
|
||||
if (milestone.reward_stamp) {
|
||||
const { error: stampError } = await supabase
|
||||
.from('user_stamps')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
stamp_def_id: milestone.reward_stamp.id,
|
||||
source: 'achievement',
|
||||
obtained_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (stampError) {
|
||||
log('warn', 'Failed to grant achievement stamp', {
|
||||
userId,
|
||||
stampDefId: milestone.reward_stamp.id,
|
||||
error: stampError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 发送成就通知
|
||||
await sendAchievementNotification(supabase, userId, milestone);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
log('error', 'Failed to grant milestone', {
|
||||
userId,
|
||||
milestoneId: milestone.id,
|
||||
error: message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送成就达成通知
|
||||
*/
|
||||
async function sendAchievementNotification(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string,
|
||||
milestone: { name: string; description?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { data: user } = await supabase
|
||||
.from('users')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (!user?.email) {
|
||||
log('warn', 'User has no email for notification', { userId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录通知任务(实际发送由其他服务处理)
|
||||
log('info', 'Sending achievement notification', {
|
||||
userId,
|
||||
milestoneName: milestone.name
|
||||
});
|
||||
|
||||
// 可以在此调用 Supabase Edge Function 发送邮件
|
||||
// 或使用消息队列异步处理
|
||||
} catch (error) {
|
||||
log('error', 'Failed to send achievement notification', { userId, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有需要检查里程碑的用户
|
||||
*/
|
||||
async function getUsersToCheck(
|
||||
supabase: ReturnType<typeof createServiceClient>
|
||||
): Promise<string[]> {
|
||||
// 获取所有有活跃里程碑的用户
|
||||
// 优先检查最近活跃的用户
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('email_confirmed', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1000); // 限制每次检查的用户数量
|
||||
|
||||
if (error) {
|
||||
log('error', 'Failed to fetch users', { error: error.message });
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.map(u => u.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cron/check-milestones
|
||||
* 检查用户里程碑达成情况
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
// 验证 Cron 请求
|
||||
if (!verifyCronRequest(request)) {
|
||||
log('warn', 'Unauthorized cron request');
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createServiceClient();
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
log('info', 'Starting milestone check');
|
||||
|
||||
// 1. 获取所有活跃里程碑
|
||||
const milestones = await getActiveMilestones(supabase);
|
||||
log('info', 'Found active milestones', { count: milestones.length });
|
||||
|
||||
if (milestones.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No active milestones',
|
||||
usersChecked: 0,
|
||||
newAchievements: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 获取需要检查的用户
|
||||
const userIds = await getUsersToCheck(supabase);
|
||||
log('info', 'Found users to check', { count: userIds.length });
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No users to check',
|
||||
usersChecked: 0,
|
||||
newAchievements: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 遍历用户检查里程碑
|
||||
let usersChecked = 0;
|
||||
let totalNewAchievements = 0;
|
||||
const errors: Array<{ userId: string; error: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
// 获取用户已获得的成就
|
||||
const achievedIds = await getUserAchievements(supabase, userId);
|
||||
|
||||
// 检查新成就
|
||||
const newIds = await checkUserMilestones(
|
||||
supabase,
|
||||
userId,
|
||||
milestones,
|
||||
achievedIds
|
||||
);
|
||||
|
||||
if (newIds.length > 0) {
|
||||
totalNewAchievements += newIds.length;
|
||||
log('info', 'User earned new achievements', {
|
||||
userId,
|
||||
achievementCount: newIds.length
|
||||
});
|
||||
}
|
||||
|
||||
usersChecked++;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
errors.push({ userId, error: message });
|
||||
}
|
||||
|
||||
// 添加小延迟避免数据库压力
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
log('info', 'Milestone check completed', {
|
||||
usersChecked,
|
||||
totalNewAchievements,
|
||||
errors: errors.length,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
usersChecked,
|
||||
totalNewAchievements,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
log('error', 'Milestone check cron job failed', { error: message });
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
276
src/app/api/cron/daily-stamps/route.ts
Normal file
276
src/app/api/cron/daily-stamps/route.ts
Normal file
@ -0,0 +1,276 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// Vercel Cron 请求验证 header
|
||||
const CRON_SECRET = process.env.CRON_SECRET || 'vercel-cron-secret';
|
||||
|
||||
/**
|
||||
* 验证请求是否来自 Vercel Cron
|
||||
*/
|
||||
function verifyCronRequest(request: NextRequest): boolean {
|
||||
const cronKey = request.headers.get('x-vercel-cron');
|
||||
return cronKey === CRON_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Supabase Service Role 客户端
|
||||
*/
|
||||
function createServiceClient() {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
return createClient(supabaseUrl, serviceRoleKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录日志
|
||||
*/
|
||||
function log(level: string, message: string, data?: Record<string, unknown>) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(JSON.stringify({ timestamp, level, message, ...data }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日邮票配置
|
||||
*/
|
||||
const STAMP_GRANT_CONFIG = {
|
||||
free: 1,
|
||||
premium: 3
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取每日邮票定义
|
||||
*/
|
||||
async function getDailyStampDef(supabase: ReturnType<typeof createServiceClient>) {
|
||||
const { data, error } = await supabase
|
||||
.from('stamp_definitions')
|
||||
.select('id')
|
||||
.eq('code', 'daily_default')
|
||||
.eq('is_active', true)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
log('error', 'Daily stamp definition not found', { error });
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户今日是否已发放邮票
|
||||
*/
|
||||
async function hasUserReceivedStampsToday(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const { count, error } = await supabase
|
||||
.from('daily_stats')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('stat_date', today)
|
||||
.gte('stamps_granted', 1);
|
||||
|
||||
if (error) {
|
||||
log('warn', 'Failed to check daily stats', { userId, error: error.message });
|
||||
return false;
|
||||
}
|
||||
|
||||
return (count || 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单个用户发放邮票
|
||||
*/
|
||||
async function grantDailyStamps(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
user: {
|
||||
id: string;
|
||||
subscription_status: string;
|
||||
},
|
||||
stampDefId: string
|
||||
): Promise<{ success: boolean; granted: number; error?: string }> {
|
||||
try {
|
||||
// 检查今日是否已发放
|
||||
if (await hasUserReceivedStampsToday(supabase, user.id)) {
|
||||
return { success: true, granted: 0 };
|
||||
}
|
||||
|
||||
const stampCount = STAMP_GRANT_CONFIG[user.subscription_status as keyof typeof STAMP_GRANT_CONFIG] || 1;
|
||||
|
||||
// 批量插入邮票记录
|
||||
const stampRecords = Array.from({ length: stampCount }, () => ({
|
||||
user_id: user.id,
|
||||
stamp_def_id: stampDefId,
|
||||
source: 'daily_grant',
|
||||
obtained_at: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('user_stamps')
|
||||
.insert(stampRecords);
|
||||
|
||||
if (insertError) {
|
||||
throw insertError;
|
||||
}
|
||||
|
||||
// 更新或创建每日统计
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const { error: statsError } = await supabase
|
||||
.from('daily_stats')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
stat_date: today,
|
||||
stamps_granted: stampCount,
|
||||
stamps_used: 0,
|
||||
last_active_at: new Date().toISOString()
|
||||
}, {
|
||||
onConflict: 'user_id, stat_date'
|
||||
});
|
||||
|
||||
if (statsError) {
|
||||
log('warn', 'Failed to update daily stats', { userId: user.id, error: statsError.message });
|
||||
}
|
||||
|
||||
log('info', 'Stamps granted to user', { userId: user.id, count: stampCount });
|
||||
return { success: true, granted: stampCount };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
log('error', 'Failed to grant stamps', { userId: user.id, error: message });
|
||||
return { success: false, granted: 0, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有活跃用户
|
||||
*/
|
||||
async function getActiveUsers(supabase: ReturnType<typeof createServiceClient>) {
|
||||
// 获取所有已确认邮箱的用户(排除未验证的)
|
||||
const { data: users, error } = await supabase
|
||||
.from('users')
|
||||
.select('id, email, subscription_status')
|
||||
.eq('email_confirmed', true);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return users || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录每日统计汇总
|
||||
*/
|
||||
async function recordDailySummary(
|
||||
supabase: ReturnType<typeof createServiceClient>,
|
||||
totalUsers: number,
|
||||
totalStampsGranted: number
|
||||
): Promise<void> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// 记录系统级别的每日统计
|
||||
const { error } = await supabase
|
||||
.from('system_stats')
|
||||
.upsert({
|
||||
stat_date: today,
|
||||
stat_type: 'daily_stamps',
|
||||
total_users: totalUsers,
|
||||
total_stamps_granted: totalStampsGranted,
|
||||
updated_at: new Date().toISOString()
|
||||
}, {
|
||||
onConflict: 'stat_date, stat_type'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log('warn', 'Failed to record system stats', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cron/daily-stamps
|
||||
* 每日邮票发放任务
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
// 验证 Cron 请求
|
||||
if (!verifyCronRequest(request)) {
|
||||
log('warn', 'Unauthorized cron request');
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createServiceClient();
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
log('info', 'Starting daily stamps distribution');
|
||||
|
||||
// 1. 获取每日邮票定义
|
||||
const stampDefId = await getDailyStampDef(supabase);
|
||||
if (!stampDefId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Daily stamp definition not found' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 获取所有活跃用户
|
||||
const users = await getActiveUsers(supabase);
|
||||
log('info', 'Found active users', { count: users.length });
|
||||
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No active users',
|
||||
usersProcessed: 0,
|
||||
totalStampsGranted: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 批量处理用户邮票发放
|
||||
let totalStampsGranted = 0;
|
||||
let usersProcessed = 0;
|
||||
const errors: Array<{ userId: string; error: string }> = [];
|
||||
|
||||
for (const user of users) {
|
||||
const result = await grantDailyStamps(supabase, user, stampDefId);
|
||||
|
||||
if (result.success) {
|
||||
usersProcessed++;
|
||||
totalStampsGranted += result.granted;
|
||||
} else if (result.error) {
|
||||
errors.push({ userId: user.id, error: result.error });
|
||||
}
|
||||
|
||||
// 添加小延迟避免数据库压力
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// 4. 记录每日统计汇总
|
||||
await recordDailySummary(supabase, users.length, totalStampsGranted);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
log('info', 'Daily stamps distribution completed', {
|
||||
usersProcessed,
|
||||
totalStampsGranted,
|
||||
errors: errors.length,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
usersProcessed,
|
||||
totalStampsGranted,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
log('error', 'Daily stamps cron job failed', { error: message });
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
352
src/app/api/cron/process-letters/route.ts
Normal file
352
src/app/api/cron/process-letters/route.ts
Normal file
@ -0,0 +1,352 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// Vercel Cron 请求验证 header
|
||||
const CRON_SECRET = process.env.CRON_SECRET || 'vercel-cron-secret';
|
||||
|
||||
/**
|
||||
* 验证请求是否来自 Vercel Cron
|
||||
*/
|
||||
function verifyCronRequest(request: NextRequest): boolean {
|
||||
const cronKey = request.headers.get('x-vercel-cron');
|
||||
return cronKey === CRON_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Supabase Service Role 客户端
|
||||
*/
|
||||
function createServiceClient(): SupabaseClient {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
return createClient(supabaseUrl, serviceRoleKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录日志
|
||||
*/
|
||||
function log(level: string, message: string, data?: Record<string, unknown>) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(JSON.stringify({ timestamp, level, message, ...data }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 速率限制 - 防止 API 调用过载
|
||||
*/
|
||||
const processingLetters = new Set<string>();
|
||||
const RATE_LIMIT_WINDOW = 60000; // 1 分钟
|
||||
const MAX_CONCURRENT = 5;
|
||||
|
||||
async function acquireRateLimit(letterId: string): Promise<boolean> {
|
||||
if (processingLetters.size >= MAX_CONCURRENT) {
|
||||
return false;
|
||||
}
|
||||
processingLetters.add(letterId);
|
||||
setTimeout(() => processingLetters.delete(letterId), RATE_LIMIT_WINDOW);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 DeepSeek API 生成回复
|
||||
*/
|
||||
async function generateAIReply(
|
||||
systemPrompt: string,
|
||||
userContent: string,
|
||||
language: string
|
||||
): Promise<string> {
|
||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY!;
|
||||
|
||||
const response = await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${deepseekApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userContent }
|
||||
],
|
||||
max_tokens: 1000,
|
||||
temperature: 0.7
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
log('error', 'DeepSeek API error', { status: response.status, error });
|
||||
throw new Error(`DeepSeek API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.choices[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析信件内容,提取标签
|
||||
*/
|
||||
async function analyzeLetter(letterContent: string): Promise<{
|
||||
mood_tags: string[];
|
||||
topic_tags: string[];
|
||||
keywords: string[];
|
||||
sentiment_score: number;
|
||||
}> {
|
||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY!;
|
||||
|
||||
const analyzePrompt = `分析用户信件内容,输出 JSON 格式:
|
||||
|
||||
{
|
||||
"mood_tags": ["主要心情标签,最多3个"],
|
||||
"topic_tags": ["涉及主题,最多3个"],
|
||||
"keywords": ["关键词语,最多5个"],
|
||||
"sentiment_score": -1.0到1.0的情感分数
|
||||
}
|
||||
|
||||
心情标签选项:happy, hopeful, anxious, confused, peaceful, lonely, frustrated, excited, melancholic, grateful, determined, overwhelmed
|
||||
|
||||
主题标签选项:career, relationship, family, health, growth, finance, education, creativity, lifestyle, social, personal_identity, life_transition
|
||||
|
||||
信件内容:
|
||||
${letterContent}
|
||||
|
||||
请直接输出 JSON,不要其他内容:`;
|
||||
|
||||
const response = await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${deepseekApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
messages: [{ role: 'user', content: analyzePrompt }],
|
||||
max_tokens: 200,
|
||||
temperature: 0.3
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
log('warn', 'Analyze API failed, using defaults');
|
||||
return { mood_tags: [], topic_tags: [], keywords: [], sentiment_score: 0 };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content || '{}';
|
||||
|
||||
try {
|
||||
// 清理 JSON 输出,可能包含 markdown 代码块
|
||||
const cleanJson = content.replace(/```json?/g, '').replace(/```/g, '').trim();
|
||||
return JSON.parse(cleanJson);
|
||||
} catch (e) {
|
||||
log('warn', 'Failed to parse analyze result', { content });
|
||||
return { mood_tags: [], topic_tags: [], keywords: [], sentiment_score: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单封信件
|
||||
*/
|
||||
async function processLetter(
|
||||
supabase: SupabaseClient,
|
||||
letter: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
agents: {
|
||||
system_prompt: string;
|
||||
name: string;
|
||||
};
|
||||
users: {
|
||||
language: string;
|
||||
};
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const { id, user_id, content, agents, users } = letter;
|
||||
|
||||
if (!await acquireRateLimit(id)) {
|
||||
return { success: false, error: 'Rate limit exceeded' };
|
||||
}
|
||||
|
||||
try {
|
||||
log('info', 'Processing letter', { letterId: id });
|
||||
|
||||
// 1. 生成 AI 回复
|
||||
const aiReply = await generateAIReply(
|
||||
agents.system_prompt,
|
||||
content,
|
||||
users.language
|
||||
);
|
||||
|
||||
// 2. 分析信件内容
|
||||
const analysis = await analyzeLetter(content);
|
||||
|
||||
// 3. 更新信件状态
|
||||
const { error: updateError } = await (supabase
|
||||
.from('letters') as any)
|
||||
.update({
|
||||
ai_reply: aiReply,
|
||||
status: 'replied' as const,
|
||||
replied_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', id);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
// 4. 创建成长标签记录
|
||||
const { error: tagError } = await (supabase
|
||||
.from('growth_tags') as any)
|
||||
.insert({
|
||||
user_id,
|
||||
letter_id: id,
|
||||
mood_tags: analysis.mood_tags,
|
||||
topic_tags: analysis.topic_tags,
|
||||
keywords: analysis.keywords,
|
||||
sentiment_score: analysis.sentiment_score
|
||||
});
|
||||
|
||||
if (tagError) {
|
||||
log('warn', 'Failed to create growth tags', { error: tagError.message });
|
||||
}
|
||||
|
||||
// 5. 触发邮件通知(异步)
|
||||
await sendReplyNotification(supabase, user_id, id);
|
||||
|
||||
log('info', 'Letter processed successfully', { letterId: id });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
log('error', 'Failed to process letter', { letterId: id, error: message });
|
||||
|
||||
// 更新为失败状态
|
||||
await (supabase
|
||||
.from('letters') as any)
|
||||
.update({
|
||||
status: 'failed' as const,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', id);
|
||||
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送回复通知邮件
|
||||
*/
|
||||
async function sendReplyNotification(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
letterId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { data: user } = await (supabase
|
||||
.from('users') as any)
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
const { data: letter } = await (supabase
|
||||
.from('letters') as any)
|
||||
.select('ai_reply')
|
||||
.eq('id', letterId)
|
||||
.single();
|
||||
|
||||
if (!user?.email || !letter?.ai_reply) {
|
||||
log('warn', 'Missing email or reply for notification', { userId, letterId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 Supabase Email 发送通知
|
||||
// 这里调用 Supabase Edge Function 或自定义邮件服务
|
||||
log('info', 'Sending reply notification', { userId, letterId });
|
||||
|
||||
// 实际邮件发送逻辑需要在 Supabase Edge Function 中实现
|
||||
// 或使用其他邮件服务(如 Resend、SendGrid)
|
||||
} catch (error) {
|
||||
log('error', 'Failed to send notification', { userId, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cron/process-letters
|
||||
* 处理待回复的信件
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
// 验证 Cron 请求
|
||||
if (!verifyCronRequest(request)) {
|
||||
log('warn', 'Unauthorized cron request');
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createServiceClient();
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 获取所有待处理信件
|
||||
const { data: pendingLetters, error } = await supabase
|
||||
.from('letters')
|
||||
.select(`
|
||||
*,
|
||||
agents (system_prompt, name),
|
||||
users (language)
|
||||
`)
|
||||
.eq('status', 'pending')
|
||||
.lte('scheduled_at', new Date().toISOString())
|
||||
.limit(20);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!pendingLetters || pendingLetters.length === 0) {
|
||||
log('info', 'No pending letters to process');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No pending letters',
|
||||
processed: 0,
|
||||
failed: 0
|
||||
});
|
||||
}
|
||||
|
||||
log('info', 'Found pending letters', { count: pendingLetters.length });
|
||||
|
||||
// 2. 批量处理信件
|
||||
const results = await Promise.allSettled(
|
||||
pendingLetters.map(letter => processLetter(supabase, letter))
|
||||
);
|
||||
|
||||
const successful = results.filter(
|
||||
(r): r is PromiseFulfilledResult<{ success: true }> =>
|
||||
r.status === 'fulfilled' && r.value.success
|
||||
).length;
|
||||
|
||||
const failed = results.length - successful;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
log('info', 'Batch processing completed', {
|
||||
total: results.length,
|
||||
successful,
|
||||
failed,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processed: successful,
|
||||
failed,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
log('error', 'Cron job failed', { error: message });
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/app/api/dictionary/route.ts
Normal file
15
src/app/api/dictionary/route.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDictionary } from '@/get-dictionary';
|
||||
import type { Locale } from '@/i18n-config';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale') as Locale || 'zh-CN';
|
||||
|
||||
try {
|
||||
const dict = await getDictionary(locale);
|
||||
return NextResponse.json(dict);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to load dictionary' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
81
src/app/api/onboarding/submit/route.ts
Normal file
81
src/app/api/onboarding/submit/route.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import type { OnboardingAnswers } from '@/types';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { question_1, question_2, question_3, question_4 } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!question_1 || !question_2 || !question_3 || !question_4) {
|
||||
return NextResponse.json(
|
||||
{ error: 'All questions must be answered' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Insert onboarding answers
|
||||
const { data, error } = await supabase
|
||||
.from('onboarding_answers')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
question_1,
|
||||
question_2,
|
||||
question_3,
|
||||
question_4,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id',
|
||||
ignoreDuplicates: false,
|
||||
}
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving onboarding answers:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update user profile to mark onboarding as completed
|
||||
const { error: profileError } = await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
has_completed_onboarding: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', user.id);
|
||||
|
||||
if (profileError) {
|
||||
console.error('Error updating user profile:', profileError);
|
||||
// Don't fail the request, onboarding answers are saved
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
message: 'Onboarding completed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in onboarding submit:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
97
src/app/globals.css
Normal file
97
src/app/globals.css
Normal file
@ -0,0 +1,97 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Onboarding Animations */
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(10deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-gentle {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-gentle {
|
||||
animation: bounce-gentle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
1
src/app/layout.tsx
Normal file
1
src/app/layout.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./[locale]/layout";
|
||||
10
src/app/page.tsx
Normal file
10
src/app/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { i18n } from "@/i18n-config";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
redirect(`/${i18n.defaultLocale}`);
|
||||
}
|
||||
179
src/components/agents/AgentCard.tsx
Normal file
179
src/components/agents/AgentCard.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Sparkles,
|
||||
Calendar,
|
||||
MessageCircle,
|
||||
MoreVertical,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Agent } from '@/types';
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent;
|
||||
locale: string;
|
||||
isPremium?: boolean;
|
||||
onDelete?: (agent: Agent) => void;
|
||||
}
|
||||
|
||||
const toneLabels: Record<string, string> = {
|
||||
gentle: '温柔',
|
||||
professional: '专业',
|
||||
warm: '温暖',
|
||||
wise: '智慧',
|
||||
};
|
||||
|
||||
export function AgentCard({ agent, locale, isPremium = false, onDelete }: AgentCardProps) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('确定要删除这个智能体吗?')) {
|
||||
onDelete?.(agent);
|
||||
}
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant={agent.is_default ? 'elevated' : 'default'}
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-all duration-300 hover:shadow-lg hover:-translate-y-1',
|
||||
agent.is_default && 'ring-2 ring-purple-200 bg-gradient-to-br from-purple-50 to-white'
|
||||
)}
|
||||
>
|
||||
{/* Default badge */}
|
||||
{agent.is_default && (
|
||||
<div className="absolute top-0 right-0 bg-gradient-to-r from-purple-500 to-pink-500 text-white text-xs px-3 py-1 rounded-bl-lg flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
默认
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-14 h-14 rounded-full flex items-center justify-center text-2xl',
|
||||
agent.is_default
|
||||
? 'bg-gradient-to-br from-purple-400 to-pink-400 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
)}
|
||||
>
|
||||
{agent.avatar_url ? (
|
||||
<img
|
||||
src={agent.avatar_url}
|
||||
alt={agent.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="w-7 h-7" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{agent.name}
|
||||
{agent.is_custom && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full">
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-gray-500">
|
||||
{agent.is_default
|
||||
? '根据你的回答生成的专属智能体'
|
||||
: `创建于 ${new Date(agent.created_at).toLocaleDateString()}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu button for custom agents */}
|
||||
{agent.is_custom && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 top-full mt-1 w-32 bg-white rounded-lg shadow-lg border border-gray-100 py-1 z-10">
|
||||
<button className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
设置
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{/* Personality traits */}
|
||||
{agent.personality && agent.personality.traits && agent.personality.traits.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
性格特点
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-purple-100 text-purple-600 rounded-full">
|
||||
{toneLabels[agent.personality.tone] || '温暖'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.personality.traits.slice(0, 4).map((trait, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded-md"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background */}
|
||||
{agent.background && (
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
{agent.background}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-4 pt-4 border-t border-gray-100">
|
||||
<Link href={`/${locale}/letters/new?agent_id=${agent.id}`} className="flex-1">
|
||||
<Button variant="primary" size="sm" className="w-full">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
写信
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${locale}/agents/${agent.id}`} className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Calendar className="w-4 h-4" />
|
||||
查看
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
200
src/components/agents/AgentList.tsx
Normal file
200
src/components/agents/AgentList.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Users, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { AgentCard } from './AgentCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { Agent } from '@/types';
|
||||
|
||||
interface AgentListProps {
|
||||
locale: string;
|
||||
isPremium?: boolean;
|
||||
onCreateAgent?: () => void;
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
data: {
|
||||
data: Agent[];
|
||||
default_agent: Agent | null;
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function AgentList({ locale, isPremium = false, onCreateAgent }: AgentListProps) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [defaultAgent, setDefaultAgent] = useState<Agent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchAgents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('/api/agents');
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = `/${locale}/login`;
|
||||
return;
|
||||
}
|
||||
throw new Error('获取智能体列表失败');
|
||||
}
|
||||
|
||||
const data: AgentsResponse = await response.json();
|
||||
setAgents(data.data.data || []);
|
||||
setDefaultAgent(data.data.default_agent);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgents();
|
||||
}, [fetchAgents]);
|
||||
|
||||
const handleDelete = async (agent: Agent) => {
|
||||
if (!agent.is_custom) return;
|
||||
|
||||
try {
|
||||
setDeletingId(agent.id);
|
||||
const response = await fetch(`/api/agents?id=${agent.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除失败');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
fetchAgents();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '删除失败');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Combine default agent with custom agents, ensuring default is first
|
||||
const allAgents = defaultAgent
|
||||
? [defaultAgent, ...agents.filter((a) => a.id !== defaultAgent.id)]
|
||||
: agents;
|
||||
|
||||
// Calculate remaining slots
|
||||
const maxAgents = isPremium ? 3 : 1;
|
||||
const remainingSlots = maxAgents - allAgents.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-purple-500" />
|
||||
<span className="ml-3 text-gray-500">加载中...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-400 mb-4" />
|
||||
<p className="text-gray-600 mb-4">{error}</p>
|
||||
<Button onClick={fetchAgents} variant="outline">
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with create button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-500" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
我的智能体 ({allAgents.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{remainingSlots > 0 && (
|
||||
<Button
|
||||
onClick={onCreateAgent}
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={deletingId !== null}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
创建智能体
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent limit warning */}
|
||||
{remainingSlots === 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-amber-800 font-medium">已达到智能体上限</p>
|
||||
<p className="text-sm text-amber-600 mt-1">
|
||||
{isPremium
|
||||
? '您已达到 3 个智能体的上限。如需更多,请升级套餐。'
|
||||
: '免费用户最多只能拥有 1 个智能体。升级到高级版可创建最多 3 个智能体。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{allAgents.length === 0 && (
|
||||
<div className="text-center py-16 bg-gray-50 rounded-xl">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
还没有智能体
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
完成 onboarding 后,系统会自动为您生成专属的智能体
|
||||
</p>
|
||||
<Button onClick={onCreateAgent} variant="primary">
|
||||
创建智能体
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
{allAgents.length > 0 && (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-2">
|
||||
{allAgents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
locale={locale}
|
||||
isPremium={isPremium}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading overlay for delete */}
|
||||
{deletingId && (
|
||||
<div className="fixed inset-0 bg-black/20 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-purple-500" />
|
||||
<span>删除中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/components/agents/index.ts
Normal file
2
src/components/agents/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { AgentCard } from './AgentCard';
|
||||
export { AgentList } from './AgentList';
|
||||
243
src/components/auth/LoginForm.tsx
Normal file
243
src/components/auth/LoginForm.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
import { createBrowserSupabaseClient } from '@/lib/supabase/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Loader2, Mail, Lock, Eye, EyeOff, ArrowRight } from 'lucide-react';
|
||||
import type { Dictionary } from '@/get-dictionary';
|
||||
|
||||
interface LoginFormProps {
|
||||
dictionary: Dictionary['auth'];
|
||||
}
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('invalid_email'),
|
||||
password: z.string().min(1, 'password_required'),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
export function LoginForm({ dictionary }: LoginFormProps) {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const router = useRouter();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const supabase = createBrowserSupabaseClient();
|
||||
|
||||
try {
|
||||
const { error: authError } = await supabase.auth.signInWithPassword({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
setError(authError.message);
|
||||
} else {
|
||||
router.push(`/${locale}/onboarding`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : dictionary.loginFailed || 'Login failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const supabase = createBrowserSupabaseClient();
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/${locale}/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : dictionary.loginFailed || 'Google login failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo / Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href={`/${locale}`} className="inline-block">
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 bg-clip-text text-transparent">
|
||||
{dictionary.title}
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mt-2 text-gray-600">{dictionary.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-xl bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<h2 className="text-2xl font-semibold text-center text-gray-900">
|
||||
{dictionary.signIn}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
placeholder={dictionary.email}
|
||||
className={cn(
|
||||
'w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 bg-white',
|
||||
'placeholder:text-gray-400',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
|
||||
errors.email && 'border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{dictionary.emailRequired}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={dictionary.password}
|
||||
className={cn(
|
||||
'w-full pl-10 pr-12 py-3 rounded-lg border border-gray-200 bg-white',
|
||||
'placeholder:text-gray-400',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
|
||||
errors.password && 'border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-500">{dictionary.passwordRequired}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
href={`/${locale}/forgot-password`}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 transition-colors"
|
||||
>
|
||||
{dictionary.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{dictionary.signIn}
|
||||
{!isLoading && <ArrowRight className="h-4 w-4 ml-2" />}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-white text-gray-500">或</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Login */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
{dictionary.signInWithGoogle}
|
||||
</Button>
|
||||
|
||||
{/* Sign up link */}
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
{dictionary.noAccount}{' '}
|
||||
<Link
|
||||
href={`/${locale}/register`}
|
||||
className="text-purple-600 hover:text-purple-700 font-medium transition-colors"
|
||||
>
|
||||
{dictionary.signUp}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
321
src/components/auth/RegisterForm.tsx
Normal file
321
src/components/auth/RegisterForm.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
import { createBrowserSupabaseClient } from '@/lib/supabase/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Loader2, Mail, Lock, Eye, EyeOff, ArrowRight, Check } from 'lucide-react';
|
||||
import type { Dictionary } from '@/get-dictionary';
|
||||
|
||||
interface RegisterFormProps {
|
||||
dictionary: Dictionary['auth'];
|
||||
}
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
email: z.string().email('invalid_email'),
|
||||
password: z.string().min(6, 'password_min_length'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'password_mismatch',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
|
||||
export function RegisterForm({ dictionary }: RegisterFormProps) {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const router = useRouter();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
if (!agreedToTerms) {
|
||||
setError(dictionary.termsRequired || 'Please agree to the terms');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const supabase = createBrowserSupabaseClient();
|
||||
|
||||
try {
|
||||
const { error: authError } = await supabase.auth.signUp({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
options: {
|
||||
data: {
|
||||
language: locale,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
setError(authError.message);
|
||||
} else {
|
||||
router.push(`/${locale}/onboarding`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : dictionary.registrationFailed || 'Registration failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const supabase = createBrowserSupabaseClient();
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/${locale}/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : dictionary.registrationFailed || 'Google signup failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo / Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href={`/${locale}`} className="inline-block">
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 bg-clip-text text-transparent">
|
||||
{dictionary.signUpTitle.split(' ')[0]}
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mt-2 text-gray-600">{dictionary.signUpSubtitle}</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-xl bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<h2 className="text-2xl font-semibold text-center text-gray-900">
|
||||
{dictionary.signUp}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
placeholder={dictionary.email}
|
||||
className={cn(
|
||||
'w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 bg-white',
|
||||
'placeholder:text-gray-400',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
|
||||
errors.email && 'border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{dictionary.emailRequired}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={dictionary.password}
|
||||
className={cn(
|
||||
'w-full pl-10 pr-12 py-3 rounded-lg border border-gray-200 bg-white',
|
||||
'placeholder:text-gray-400',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
|
||||
errors.password && 'border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-500">{dictionary.passwordMinLength || 'Password must be at least 6 characters'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
{...register('confirmPassword')}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder={dictionary.confirmPassword}
|
||||
className={cn(
|
||||
'w-full pl-10 pr-12 py-3 rounded-lg border border-gray-200 bg-white',
|
||||
'placeholder:text-gray-400',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
|
||||
errors.confirmPassword && 'border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-500">{dictionary.passwordMismatch}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms checkbox */}
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgreedToTerms(!agreedToTerms)}
|
||||
className={cn(
|
||||
'flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-all',
|
||||
agreedToTerms
|
||||
? 'bg-purple-500 border-purple-500 text-white'
|
||||
: 'border-gray-300 hover:border-purple-400'
|
||||
)}
|
||||
>
|
||||
{agreedToTerms && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
checked={agreedToTerms}
|
||||
onChange={() => setAgreedToTerms(!agreedToTerms)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label htmlFor="terms" className="text-sm text-gray-600">
|
||||
{dictionary.agreeTerms}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-purple-600 hover:text-purple-700 mx-1"
|
||||
>
|
||||
{dictionary.termsOfService}
|
||||
</Link>
|
||||
{dictionary.and}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-purple-600 hover:text-purple-700 mx-1"
|
||||
>
|
||||
{dictionary.privacyPolicy}
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
disabled={!agreedToTerms}
|
||||
>
|
||||
{dictionary.signUp}
|
||||
{!isLoading && <ArrowRight className="h-4 w-4 ml-2" />}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-white text-gray-500">或</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Login */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
{dictionary.signInWithGoogle}
|
||||
</Button>
|
||||
|
||||
{/* Sign in link */}
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
{dictionary.hasAccount}{' '}
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
className="text-purple-600 hover:text-purple-700 font-medium transition-colors"
|
||||
>
|
||||
{dictionary.signIn}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/components/onboarding/OnboardingProgress.tsx
Normal file
72
src/components/onboarding/OnboardingProgress.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface OnboardingProgressProps {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OnboardingProgress({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
className,
|
||||
}: OnboardingProgressProps) {
|
||||
const steps = Array.from({ length: totalSteps }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-2', className)}>
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = step <= currentStep;
|
||||
const isCurrent = step === currentStep;
|
||||
|
||||
return (
|
||||
<div key={step} className="flex items-center">
|
||||
{/* Step circle */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
|
||||
'transition-all duration-300',
|
||||
isCompleted
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-400',
|
||||
isCurrent && 'ring-2 ring-pink-200 ring-offset-2'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
step
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connector line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-12 h-0.5 mx-1 transition-all duration-300',
|
||||
step < currentStep
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-500'
|
||||
: 'bg-gray-200'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
src/components/onboarding/QuestionCard.tsx
Normal file
151
src/components/onboarding/QuestionCard.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useOnboardingStore } from '@/store';
|
||||
import { OnboardingProgress } from './OnboardingProgress';
|
||||
import type { Dictionary } from '@/get-dictionary';
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: string;
|
||||
questionKey: 'question_1' | 'question_2' | 'question_3' | 'question_4';
|
||||
step: number;
|
||||
dict: Dictionary['onboarding'];
|
||||
placeholder: string;
|
||||
encouragement?: string;
|
||||
onNext: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export function QuestionCard({
|
||||
question,
|
||||
questionKey,
|
||||
step,
|
||||
dict,
|
||||
placeholder,
|
||||
encouragement,
|
||||
onNext,
|
||||
onBack,
|
||||
}: QuestionCardProps) {
|
||||
const { answers, setAnswer, setSubmitting } = useOnboardingStore();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
defaultValues: {
|
||||
answer: answers[questionKey] || '',
|
||||
},
|
||||
});
|
||||
|
||||
const answerValue = watch('answer');
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (data: FormData) => {
|
||||
if (!data.answer.trim()) return;
|
||||
|
||||
setAnswer(questionKey, data.answer);
|
||||
setSubmitting(true);
|
||||
|
||||
// Simulate API call delay for smooth transition
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
setSubmitting(false);
|
||||
onNext();
|
||||
},
|
||||
[questionKey, setAnswer, setSubmitting, onNext]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
{/* Progress indicator */}
|
||||
<div className="mb-8">
|
||||
<OnboardingProgress currentStep={step} totalSteps={4} />
|
||||
</div>
|
||||
|
||||
{/* Question card */}
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 md:p-10">
|
||||
{/* Decorative gradient line */}
|
||||
<div className="h-1 w-20 bg-gradient-to-r from-pink-500 to-purple-500 rounded-full mb-6" />
|
||||
|
||||
{/* Question */}
|
||||
<h2 className="text-2xl md:text-3xl font-semibold text-gray-900 mb-4 leading-tight">
|
||||
{question}
|
||||
</h2>
|
||||
|
||||
{/* Encouragement */}
|
||||
{encouragement && (
|
||||
<p className="text-gray-500 mb-6 text-lg">{encouragement}</p>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Textarea
|
||||
{...register('answer', {
|
||||
required: dict.answerRequired,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[150px] text-lg"
|
||||
error={errors.answer?.message}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
{onBack ? (
|
||||
<Button type="button" variant="ghost" onClick={onBack}>
|
||||
{dict.back}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Word count hint */}
|
||||
<span
|
||||
className={`text-sm ${
|
||||
answerValue.length > 500
|
||||
? 'text-orange-500'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{answerValue.length}
|
||||
</span>
|
||||
|
||||
<Button type="submit" disabled={!answerValue.trim()}>
|
||||
{dict.next}
|
||||
<svg
|
||||
className="w-4 h-4 ml-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Encouraging message at bottom */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-gray-400 text-sm animate-pulse">
|
||||
{dict.keepThinking}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/components/onboarding/Questionnaire.tsx
Normal file
141
src/components/onboarding/Questionnaire.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { OnboardingProgress } from './OnboardingProgress';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useOnboardingStore } from '@/store';
|
||||
import type { Dictionary } from '@/get-dictionary';
|
||||
import type { Locale } from '@/i18n-config';
|
||||
|
||||
interface Question {
|
||||
key: 'question_1' | 'question_2' | 'question_3' | 'question_4';
|
||||
question: string;
|
||||
placeholder: string;
|
||||
encouragement: string;
|
||||
}
|
||||
|
||||
interface QuestionnaireProps {
|
||||
dict: Dictionary['onboarding'];
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const QUESTIONS: Omit<Question, 'question' | 'placeholder' | 'encouragement'>[] = [
|
||||
{ key: 'question_1' },
|
||||
{ key: 'question_2' },
|
||||
{ key: 'question_3' },
|
||||
{ key: 'question_4' },
|
||||
];
|
||||
|
||||
const ENCOURAGEMENTS: Record<string, Record<string, string>> = {
|
||||
'zh-CN': {
|
||||
question_1: '想象一下,十年后的你正在过着理想中的生活...',
|
||||
question_2: '那里有怎样的风景,谁陪伴在你身边?',
|
||||
question_3: '说出来吧,承认困扰是解决问题的第一步',
|
||||
question_4: '他们是你生命中最温暖的力量',
|
||||
},
|
||||
'zh-TW': {
|
||||
question_1: '想像一下,十年後的你正在過著理想中的生活...',
|
||||
question_2: '那裡有怎樣的風景,誰陪伴在你身邊?',
|
||||
question_3: '說出來吧,承認困擾是解決問題的第一步',
|
||||
question_4: '他們是你生命中最溫暖的力量',
|
||||
},
|
||||
'en-US': {
|
||||
question_1: 'Imagine yourself living your ideal life in 10 years...',
|
||||
question_2: 'What kind of scenery surrounds you? Who is by your side?',
|
||||
question_3: 'Speak it out - acknowledging concerns is the first step',
|
||||
question_4: 'They are the warmest force in your life',
|
||||
},
|
||||
};
|
||||
|
||||
export function Questionnaire({ dict, locale }: QuestionnaireProps) {
|
||||
const router = useRouter();
|
||||
const { currentStep, setCurrentStep, answers, setAnswer, setSubmitting, isSubmitting } =
|
||||
useOnboardingStore();
|
||||
|
||||
const encouragements = ENCOURAGEMENTS[locale] || ENCOURAGEMENTS['zh-CN'];
|
||||
|
||||
const questions: Question[] = [
|
||||
{
|
||||
key: 'question_1',
|
||||
question: dict.question1,
|
||||
placeholder: dict.placeholder,
|
||||
encouragement: encouragements.question_1,
|
||||
},
|
||||
{
|
||||
key: 'question_2',
|
||||
question: dict.question2,
|
||||
placeholder: dict.placeholder,
|
||||
encouragement: encouragements.question_2,
|
||||
},
|
||||
{
|
||||
key: 'question_3',
|
||||
question: dict.question3,
|
||||
placeholder: dict.placeholder,
|
||||
encouragement: encouragements.question_3,
|
||||
},
|
||||
{
|
||||
key: 'question_4',
|
||||
question: dict.question4,
|
||||
placeholder: dict.placeholder,
|
||||
encouragement: encouragements.question_4,
|
||||
},
|
||||
];
|
||||
|
||||
const currentQuestion = questions[currentStep];
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
if (currentStep < questions.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
// Submit all answers and navigate to complete page
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/onboarding/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(answers),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push(`/${locale}/onboarding/complete`);
|
||||
} else {
|
||||
// Fallback: navigate to complete page even if API fails
|
||||
router.push(`/${locale}/onboarding/complete`);
|
||||
}
|
||||
} catch {
|
||||
// Fallback: navigate to complete page even if API fails
|
||||
router.push(`/${locale}/onboarding/complete`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [currentStep, setCurrentStep, setSubmitting, answers, router, locale]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
}, [currentStep, setCurrentStep]);
|
||||
|
||||
if (currentStep >= questions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white py-12 px-4">
|
||||
<QuestionCard
|
||||
question={currentQuestion.question}
|
||||
questionKey={currentQuestion.key}
|
||||
step={currentStep + 1}
|
||||
dict={dict}
|
||||
placeholder={currentQuestion.placeholder}
|
||||
encouragement={currentQuestion.encouragement}
|
||||
onNext={handleNext}
|
||||
onBack={currentStep > 0 ? handleBack : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/components/onboarding/index.ts
Normal file
3
src/components/onboarding/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { OnboardingProgress } from './OnboardingProgress';
|
||||
export { QuestionCard } from './QuestionCard';
|
||||
export { Questionnaire } from './Questionnaire';
|
||||
51
src/components/ui/Button.tsx
Normal file
51
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-gradient-to-r from-pink-500 to-purple-500 text-white hover:from-pink-600 hover:to-purple-600 focus:ring-purple-500',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
|
||||
outline: 'border-2 border-gray-200 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
||||
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:ring-gray-500',
|
||||
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'text-sm px-3 py-1.5 gap-1.5',
|
||||
md: 'text-base px-4 py-2 gap-2',
|
||||
lg: 'text-lg px-6 py-3 gap-2.5',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
111
src/components/ui/Card.tsx
Normal file
111
src/components/ui/Card.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'elevated' | 'outlined';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = 'default', padding = 'md', children, ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-white border border-gray-100',
|
||||
elevated: 'bg-white shadow-lg',
|
||||
outlined: 'bg-transparent border-2 border-gray-200',
|
||||
};
|
||||
|
||||
const paddings = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl',
|
||||
variants[variant],
|
||||
paddings[padding],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('mb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-xl font-semibold text-gray-900', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
export interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||
|
||||
const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-500 mt-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
export interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('', className)} {...props} />
|
||||
)
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('mt-4 pt-4 border-t border-gray-100 flex items-center gap-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
46
src/components/ui/Input.tsx
Normal file
46
src/components/ui/Input.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, helperText, id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'w-full px-4 py-2.5 rounded-lg border border-gray-200 bg-white text-gray-900',
|
||||
'placeholder:text-gray-400',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
|
||||
'disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1.5 text-sm text-red-500">{error}</p>}
|
||||
{helperText && !error && <p className="mt-1.5 text-sm text-gray-500">{helperText}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
63
src/components/ui/Textarea.tsx
Normal file
63
src/components/ui/Textarea.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
showCount?: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, label, error, helperText, showCount, maxLength, value, id, ...props }, ref) => {
|
||||
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
const currentLength = typeof value === 'string' ? value.length : 0;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={textareaId} className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={textareaId}
|
||||
maxLength={maxLength}
|
||||
value={value}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-lg border border-gray-200 bg-white text-gray-900',
|
||||
'placeholder:text-gray-400 resize-none',
|
||||
'transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
|
||||
'disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{helperText && !error && <p className="text-sm text-gray-500">{helperText}</p>}
|
||||
</div>
|
||||
{showCount && maxLength && (
|
||||
<p className={cn(
|
||||
'text-sm',
|
||||
currentLength >= maxLength ? 'text-red-500' : 'text-gray-400'
|
||||
)}>
|
||||
{currentLength}/{maxLength}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
11
src/components/ui/index.ts
Normal file
11
src/components/ui/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps } from './Button';
|
||||
|
||||
export { Input } from './Input';
|
||||
export type { InputProps } from './Input';
|
||||
|
||||
export { Textarea } from './Textarea';
|
||||
export type { TextareaProps } from './Textarea';
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||
export type { CardProps, CardHeaderProps, CardTitleProps, CardDescriptionProps, CardContentProps, CardFooterProps } from './Card';
|
||||
148
src/dictionaries/en-US.json
Normal file
148
src/dictionaries/en-US.json
Normal file
@ -0,0 +1,148 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "Echo",
|
||||
"loading": "Loading...",
|
||||
"error": "Something went wrong",
|
||||
"retry": "Retry",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"search": "Search"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"letters": "Letters",
|
||||
"agents": "Agents",
|
||||
"stamps": "Stamps",
|
||||
"growth": "Growth",
|
||||
"settings": "Settings",
|
||||
"profile": "Profile",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Welcome Back",
|
||||
"subtitle": "Sign in to continue your journey with your future self",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"signUp": "Sign Up",
|
||||
"signIn": "Sign In",
|
||||
"signInWithGoogle": "Continue with Google",
|
||||
"emailRequired": "Please enter your email",
|
||||
"passwordRequired": "Please enter your password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"agreeTerms": "I have read and agree to the",
|
||||
"termsOfService": "Terms of Service",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"and": "and",
|
||||
"signUpTitle": "Start Your Echo Journey",
|
||||
"signUpSubtitle": "Create an account to connect with your future self"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Hello, Stranger",
|
||||
"subtitle": "Before we begin, let's get to know you better",
|
||||
"question1": "Who do you want to become in 10 years?",
|
||||
"question2": "Where do you want to be in 10 years?",
|
||||
"question3": "What is your biggest concern or anxiety right now?",
|
||||
"question4": "Who matters most to you?",
|
||||
"placeholder": "Write your thoughts here...",
|
||||
"next": "Next",
|
||||
"skip": "Skip for now",
|
||||
"complete": "Complete",
|
||||
"answerRequired": "Please enter your answer",
|
||||
"keepThinking": "Take your time...",
|
||||
"welcomeBadge": "Welcome to Echo",
|
||||
"description": "Before starting your Echo journey, we want to get to know you better. These questions will help us create a warmer, more understanding AI agent for you.",
|
||||
"step1": "Answer Questions",
|
||||
"step2": "Generate Agent",
|
||||
"step3": "Start Chatting",
|
||||
"startButton": "Start Answering",
|
||||
"skipIntro": "If you're in a hurry, you can",
|
||||
"skipLink": "skip for now",
|
||||
"completeTitle": "Amazing!",
|
||||
"completeSubtitle": "You've completed all the questions. We've generated your personalized AI agent based on your answers.",
|
||||
"encouragement": "Remember, every confession is the beginning of healing",
|
||||
"encouragementDetail": "Your future self is waiting to meet you. Stay hopeful and brave.",
|
||||
"goHome": "Go to Home",
|
||||
"redirecting": "Redirecting to home...",
|
||||
"footerNote": "Your answers are safely stored and protected"
|
||||
},
|
||||
"letter": {
|
||||
"title": "Write a Letter",
|
||||
"writeToFuture": "Write to your future self",
|
||||
"placeholder": "Share your thoughts with your future self...",
|
||||
"send": "Send Letter",
|
||||
"draftSaved": "Draft saved",
|
||||
"stampsRequired": "1 stamp required",
|
||||
"waitingTitle": "Letter Sent",
|
||||
"waitingSubtitle": "Your future self is preparing a reply",
|
||||
"waitingTime": "Expected reply in 6-12 hours",
|
||||
"viewLetter": "View Letter",
|
||||
"writeNew": "Write Another"
|
||||
},
|
||||
"agent": {
|
||||
"title": "My Agents",
|
||||
"defaultName": "Myself in 10 Years",
|
||||
"customize": "Customize",
|
||||
"personality": "Personality",
|
||||
"background": "Background",
|
||||
"createNew": "Create New Agent",
|
||||
"maxFree": "Free users can have 1 agent",
|
||||
"maxPremium": "Premium users can have up to 3 agents",
|
||||
"defaultBadge": "Default",
|
||||
"customBadge": "Custom",
|
||||
"personalityTraits": "Personality Traits",
|
||||
"writeLetter": "Write Letter",
|
||||
"view": "View",
|
||||
"settings": "Settings",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Are you sure you want to delete this agent?",
|
||||
"agentLimitReached": "Agent limit reached",
|
||||
"upgradeNotice": "Upgrade to premium to create more agents",
|
||||
"noAgents": "No agents yet",
|
||||
"noAgentsDesc": "Complete the onboarding process to generate your personalized agent",
|
||||
"createAgent": "Create Agent",
|
||||
"agentName": "Agent Name",
|
||||
"agentNameRequired": "Please enter an agent name",
|
||||
"backgroundPlaceholder": "Describe the background story of this agent...",
|
||||
"personalityPlaceholder": "e.g., warm, optimistic, understanding (comma separated)",
|
||||
"personalityTip": "Separate multiple traits with commas",
|
||||
"premiumNotice": "Note: Free users can only have 1 custom agent. Upgrade to premium to create up to 3 agents.",
|
||||
"cancel": "Cancel",
|
||||
"creating": "Creating...",
|
||||
"createdFromAnswers": "Your personalized agent generated from your answers",
|
||||
"createdOn": "Created on",
|
||||
"traitsLabel": "Personality Traits",
|
||||
"toneLabel": "Tone"
|
||||
},
|
||||
"stamps": {
|
||||
"title": "My Stamps",
|
||||
"balance": "Stamp Balance",
|
||||
"dailyGrant": "Daily Grant",
|
||||
"collection": "Stamp Collection",
|
||||
"daily": "Daily Stamps",
|
||||
"limited": "Limited Stamps",
|
||||
"achievement": "Achievement Stamps",
|
||||
"free": "Daily (Free)",
|
||||
"premium": "Daily (Premium)"
|
||||
},
|
||||
"growth": {
|
||||
"title": "Growth Tree",
|
||||
"totalLetters": "Total Letters",
|
||||
"currentStreak": "Current Streak",
|
||||
"moods": "Mood Distribution",
|
||||
"topics": "Topic Distribution",
|
||||
"milestones": "Milestones",
|
||||
"achievements": "Achievements",
|
||||
"share": "Share Growth"
|
||||
}
|
||||
}
|
||||
148
src/dictionaries/zh-CN.json
Normal file
148
src/dictionaries/zh-CN.json
Normal file
@ -0,0 +1,148 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "回声",
|
||||
"loading": "加载中...",
|
||||
"error": "出错了",
|
||||
"retry": "重试",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"next": "下一步",
|
||||
"back": "返回",
|
||||
"close": "关闭",
|
||||
"submit": "提交",
|
||||
"search": "搜索"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"letters": "信件",
|
||||
"agents": "智能体",
|
||||
"stamps": "邮票",
|
||||
"growth": "成长树",
|
||||
"settings": "设置",
|
||||
"profile": "个人中心",
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"auth": {
|
||||
"title": "欢迎回来",
|
||||
"subtitle": "登录账号,开始与未来的自己对话",
|
||||
"email": "邮箱地址",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"noAccount": "还没有账号?",
|
||||
"hasAccount": "已有账号?",
|
||||
"signUp": "注册",
|
||||
"signIn": "登录",
|
||||
"signInWithGoogle": "使用 Google 登录",
|
||||
"emailRequired": "请输入邮箱地址",
|
||||
"passwordRequired": "请输入密码",
|
||||
"passwordMismatch": "两次输入的密码不一致",
|
||||
"agreeTerms": "我已阅读并同意",
|
||||
"termsOfService": "服务条款",
|
||||
"privacyPolicy": "隐私政策",
|
||||
"and": "和",
|
||||
"signUpTitle": "开启回声之旅",
|
||||
"signUpSubtitle": "创建账号,与未来的自己对话"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "你好,陌生人",
|
||||
"subtitle": "在开始之前,让我们更了解你",
|
||||
"question1": "你希望未来 10 年成为什么样的人?",
|
||||
"question2": "你希望未来 10 年在什么地方?",
|
||||
"question3": "你现在最大的困扰/焦虑是什么?",
|
||||
"question4": "你最在乎的人是谁?",
|
||||
"placeholder": "在这里写下你的想法...",
|
||||
"next": "下一步",
|
||||
"skip": "暂不回答",
|
||||
"complete": "完成",
|
||||
"answerRequired": "请输入你的回答",
|
||||
"keepThinking": "慢慢想,不着急...",
|
||||
"welcomeBadge": "欢迎来到回声",
|
||||
"description": "在开始你的回声之旅之前,我们想更了解你。这些问题将帮助我们为你打造更温暖、更懂你的 AI 智能体。",
|
||||
"step1": "回答问题",
|
||||
"step2": "生成智能体",
|
||||
"step3": "开始对话",
|
||||
"startButton": "开始回答",
|
||||
"skipIntro": "如果赶时间,可以",
|
||||
"skipLink": "直接跳过",
|
||||
"completeTitle": "太棒了!",
|
||||
"completeSubtitle": "你已完成所有问题。我们已经根据你的回答,生成了专属于你的 AI 智能体。",
|
||||
"encouragement": "记住,每一次倾诉都是疗愈的开始",
|
||||
"encouragementDetail": "未来的你正在等待与你相遇,请保持期待与勇气。",
|
||||
"goHome": "前往首页",
|
||||
"redirecting": "正在为你跳转至首页...",
|
||||
"footerNote": "你的回答已安全储存,我们会好好守护它"
|
||||
},
|
||||
"letter": {
|
||||
"title": "写信",
|
||||
"writeToFuture": "写给未来的自己",
|
||||
"placeholder": "在这里写下你想对未来自己说的话...",
|
||||
"send": "发送信件",
|
||||
"draftSaved": "草稿已保存",
|
||||
"stampsRequired": "需要 1 张邮票",
|
||||
"waitingTitle": "信件已投递",
|
||||
"waitingSubtitle": "未来的自己正在准备给你回信",
|
||||
"waitingTime": "预计 6-12 小时内收到回复",
|
||||
"viewLetter": "查看信件",
|
||||
"writeNew": "再写一封"
|
||||
},
|
||||
"agent": {
|
||||
"title": "我的智能体",
|
||||
"defaultName": "十年后的自己",
|
||||
"customize": "自定义",
|
||||
"personality": "性格设定",
|
||||
"background": "背景故事",
|
||||
"createNew": "创建新智能体",
|
||||
"maxFree": "免费用户最多 1 个智能体",
|
||||
"maxPremium": "付费用户最多 3 个智能体",
|
||||
"defaultBadge": "默认",
|
||||
"customBadge": "自定义",
|
||||
"personalityTraits": "性格特点",
|
||||
"writeLetter": "写信",
|
||||
"view": "查看",
|
||||
"settings": "设置",
|
||||
"delete": "删除",
|
||||
"deleteConfirm": "确定要删除这个智能体吗?",
|
||||
"agentLimitReached": "已达到智能体上限",
|
||||
"upgradeNotice": "升级到高级版可创建更多智能体",
|
||||
"noAgents": "还没有智能体",
|
||||
"noAgentsDesc": "完成引导流程后,系统会自动为您生成专属的智能体",
|
||||
"createAgent": "创建智能体",
|
||||
"agentName": "智能体名称",
|
||||
"agentNameRequired": "请输入智能体名称",
|
||||
"backgroundPlaceholder": "描述这个智能体的背景故事...",
|
||||
"personalityPlaceholder": "例如:温暖、乐观、善解人意(用逗号分隔)",
|
||||
"personalityTip": "多个特点请用逗号分隔",
|
||||
"premiumNotice": "注意:免费用户最多只能拥有 1 个自定义智能体。升级到高级版可创建最多 3 个智能体。",
|
||||
"cancel": "取消",
|
||||
"creating": "创建中...",
|
||||
"createdFromAnswers": "根据你的回答生成的专属智能体",
|
||||
"createdOn": "创建于",
|
||||
"traitsLabel": "性格特点",
|
||||
"toneLabel": "语气"
|
||||
},
|
||||
"stamps": {
|
||||
"title": "我的邮票",
|
||||
"balance": "邮票余额",
|
||||
"dailyGrant": "每日发放",
|
||||
"collection": "邮票收集册",
|
||||
"daily": "每日邮票",
|
||||
"limited": "限定邮票",
|
||||
"achievement": "成就邮票",
|
||||
"free": "免费用户每日",
|
||||
"premium": "付费用户每日"
|
||||
},
|
||||
"growth": {
|
||||
"title": "成长树",
|
||||
"totalLetters": "累计信件",
|
||||
"currentStreak": "连续天数",
|
||||
"moods": "心情分布",
|
||||
"topics": "主题分布",
|
||||
"milestones": "里程碑",
|
||||
"achievements": "成就",
|
||||
"share": "分享成长"
|
||||
}
|
||||
}
|
||||
148
src/dictionaries/zh-TW.json
Normal file
148
src/dictionaries/zh-TW.json
Normal file
@ -0,0 +1,148 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "回聲",
|
||||
"loading": "載入中...",
|
||||
"error": "出錯了",
|
||||
"retry": "重試",
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"confirm": "確認",
|
||||
"delete": "刪除",
|
||||
"edit": "編輯",
|
||||
"next": "下一步",
|
||||
"back": "返回",
|
||||
"close": "關閉",
|
||||
"submit": "提交",
|
||||
"search": "搜尋"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首頁",
|
||||
"letters": "信件",
|
||||
"agents": "智能體",
|
||||
"stamps": "郵票",
|
||||
"growth": "成長樹",
|
||||
"settings": "設定",
|
||||
"profile": "個人中心",
|
||||
"logout": "登出"
|
||||
},
|
||||
"auth": {
|
||||
"title": "歡迎回來",
|
||||
"subtitle": "登入帳號,開始與未來的自己對話",
|
||||
"email": "電子郵件",
|
||||
"password": "密碼",
|
||||
"confirmPassword": "確認密碼",
|
||||
"forgotPassword": "忘記密碼?",
|
||||
"noAccount": "還沒有帳號?",
|
||||
"hasAccount": "已有帳號?",
|
||||
"signUp": "註冊",
|
||||
"signIn": "登入",
|
||||
"signInWithGoogle": "使用 Google 登入",
|
||||
"emailRequired": "請輸入電子郵件",
|
||||
"passwordRequired": "請輸入密碼",
|
||||
"passwordMismatch": "兩次輸入的密碼不一致",
|
||||
"agreeTerms": "我已閱讀並同意",
|
||||
"termsOfService": "服務條款",
|
||||
"privacyPolicy": "隱私政策",
|
||||
"and": "和",
|
||||
"signUpTitle": "開啟回聲之旅",
|
||||
"signUpSubtitle": "建立帳號,與未來的自己對話"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "你好,陌生人",
|
||||
"subtitle": "在開始之前,讓我們更了解你",
|
||||
"question1": "你希望未來 10 年成為什麼樣的人?",
|
||||
"question2": "你希望未來 10 年在什麼地方?",
|
||||
"question3": "你現在最大的困擾/焦慮是什麼?",
|
||||
"question4": "你最在乎的人是誰?",
|
||||
"placeholder": "在這裡寫下你的想法...",
|
||||
"next": "下一步",
|
||||
"skip": "暫不回答",
|
||||
"complete": "完成",
|
||||
"answerRequired": "請輸入你的回答",
|
||||
"keepThinking": "慢慢想,不著急...",
|
||||
"welcomeBadge": "歡迎來到回聲",
|
||||
"description": "在開始你的回聲之旅之前,我們想更了解你。這些問題將幫助我們為你打造更溫暖、更懂你的 AI 智能體。",
|
||||
"step1": "回答問題",
|
||||
"step2": "生成智能體",
|
||||
"step3": "開始對話",
|
||||
"startButton": "開始回答",
|
||||
"skipIntro": "如果趕時間,可以",
|
||||
"skipLink": "直接跳過",
|
||||
"completeTitle": "太棒了!",
|
||||
"completeSubtitle": "你已完成所有問題。我們已經根據你的回答,生成了專屬於你的 AI 智能體。",
|
||||
"encouragement": "記住,每一次傾訴都是療癒的開始",
|
||||
"encouragementDetail": "未來的你正在等待與你相遇,請保持期待與勇氣。",
|
||||
"goHome": "前往首頁",
|
||||
"redirecting": "正在為你跳轉至首頁...",
|
||||
"footerNote": "你的回答已安全儲存,我們會好好守護它"
|
||||
},
|
||||
"letter": {
|
||||
"title": "寫信",
|
||||
"writeToFuture": "寫給未來的自己",
|
||||
"placeholder": "在這裡寫下你想對未來自己說的話...",
|
||||
"send": "發送信件",
|
||||
"draftSaved": "草稿已儲存",
|
||||
"stampsRequired": "需要 1 張郵票",
|
||||
"waitingTitle": "信件已投遞",
|
||||
"waitingSubtitle": "未來的自己正在準備給你回信",
|
||||
"waitingTime": "預計 6-12 小時內收到回覆",
|
||||
"viewLetter": "查看信件",
|
||||
"writeNew": "再寫一封"
|
||||
},
|
||||
"agent": {
|
||||
"title": "我的智能體",
|
||||
"defaultName": "十年後的自己",
|
||||
"customize": "自訂",
|
||||
"personality": "性格設定",
|
||||
"background": "背景故事",
|
||||
"createNew": "建立新智能體",
|
||||
"maxFree": "免費用戶最多 1 個智能體",
|
||||
"maxPremium": "付費用戶最多 3 個智能體",
|
||||
"defaultBadge": "預設",
|
||||
"customBadge": "自訂",
|
||||
"personalityTraits": "性格特點",
|
||||
"writeLetter": "寫信",
|
||||
"view": "查看",
|
||||
"settings": "設定",
|
||||
"delete": "刪除",
|
||||
"deleteConfirm": "確定要刪除這個智能體嗎?",
|
||||
"agentLimitReached": "已達到智能體上限",
|
||||
"upgradeNotice": "升級到高級版可建立更多智能體",
|
||||
"noAgents": "還沒有智能體",
|
||||
"noAgentsDesc": "完成引導流程後,系統會自動為您生成專屬的智能體",
|
||||
"createAgent": "建立智能體",
|
||||
"agentName": "智能體名稱",
|
||||
"agentNameRequired": "請輸入智能體名稱",
|
||||
"backgroundPlaceholder": "描述這個智能體的背景故事...",
|
||||
"personalityPlaceholder": "例如:溫暖、樂觀、善解人意(用逗號分隔)",
|
||||
"personalityTip": "多個特點請用逗號分隔",
|
||||
"premiumNotice": "注意:免費用戶最多只能擁有 1 個自訂智能體。升級到高級版可建立最多 3 個智能體。",
|
||||
"cancel": "取消",
|
||||
"creating": "建立中...",
|
||||
"createdFromAnswers": "根據你的回答生成的專屬智能體",
|
||||
"createdOn": "建立於",
|
||||
"traitsLabel": "性格特點",
|
||||
"toneLabel": "語氣"
|
||||
},
|
||||
"stamps": {
|
||||
"title": "我的郵票",
|
||||
"balance": "郵票餘額",
|
||||
"dailyGrant": "每日發放",
|
||||
"collection": "郵票收集冊",
|
||||
"daily": "每日郵票",
|
||||
"limited": "限定郵票",
|
||||
"achievement": "成就郵票",
|
||||
"free": "免費用戶每日",
|
||||
"premium": "付費用戶每日"
|
||||
},
|
||||
"growth": {
|
||||
"title": "成長樹",
|
||||
"totalLetters": "累計信件",
|
||||
"currentStreak": "連續天數",
|
||||
"moods": "心情分佈",
|
||||
"topics": "主題分佈",
|
||||
"milestones": "里程碑",
|
||||
"achievements": "成就",
|
||||
"share": "分享成長"
|
||||
}
|
||||
}
|
||||
24
src/get-dictionary.ts
Normal file
24
src/get-dictionary.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import 'server-only';
|
||||
import type { Locale } from '@/i18n-config';
|
||||
|
||||
// Type for dictionary
|
||||
type Dictionary = {
|
||||
common: Record<string, string>;
|
||||
nav: Record<string, string>;
|
||||
auth: Record<string, string>;
|
||||
onboarding: Record<string, string>;
|
||||
letter: Record<string, string>;
|
||||
agent: Record<string, string>;
|
||||
stamps: Record<string, string>;
|
||||
growth: Record<string, string>;
|
||||
};
|
||||
|
||||
const dictionaries = {
|
||||
'zh-CN': () => import('./dictionaries/zh-CN.json').then((module) => module.default as Dictionary),
|
||||
'zh-TW': () => import('./dictionaries/zh-TW.json').then((module) => module.default as Dictionary),
|
||||
'en-US': () => import('./dictionaries/en-US.json').then((module) => module.default as Dictionary),
|
||||
};
|
||||
|
||||
export async function getDictionary(locale: Locale): Promise<Dictionary> {
|
||||
return dictionaries[locale]();
|
||||
}
|
||||
19
src/i18n-config.ts
Normal file
19
src/i18n-config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export const i18n = {
|
||||
defaultLocale: 'zh-CN',
|
||||
locales: ['zh-CN', 'zh-TW', 'en-US'],
|
||||
} as const;
|
||||
|
||||
export type Locale = (typeof i18n)['locales'][number];
|
||||
|
||||
export function isValidLocale(locale: string): locale is Locale {
|
||||
return i18n.locales.includes(locale as Locale);
|
||||
}
|
||||
|
||||
export function getLocaleFromPath(pathname: string): Locale {
|
||||
const segments = pathname.split('/');
|
||||
const maybeLocale = segments[1];
|
||||
if (isValidLocale(maybeLocale)) {
|
||||
return maybeLocale;
|
||||
}
|
||||
return i18n.defaultLocale;
|
||||
}
|
||||
347
src/lib/ai.ts
Normal file
347
src/lib/ai.ts
Normal file
@ -0,0 +1,347 @@
|
||||
import { MoodAnalysisResult, GenerateReplyOptions } from '@/types/extended';
|
||||
|
||||
/**
|
||||
* DeepSeek API 工具函数
|
||||
* 提供 AI 回复生成、心情分析、提示词生成等功能
|
||||
*/
|
||||
|
||||
// 环境变量配置
|
||||
const DEEPSEEK_API_URL = 'https://api.deepseek.com/chat/completions';
|
||||
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '';
|
||||
|
||||
// AI 配置
|
||||
const AI_CONFIG = {
|
||||
model: 'deepseek-chat',
|
||||
temperature: 0.7,
|
||||
max_tokens: {
|
||||
reply: {
|
||||
free: 500,
|
||||
premium: 1000,
|
||||
},
|
||||
analysis: 200,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* DeepSeek API 错误类型
|
||||
*/
|
||||
export class DeepSeekError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DeepSeekError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数(用于重试机制)
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数退避重试装饰器
|
||||
*/
|
||||
function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
baseDelay: number = 1000
|
||||
): Promise<T> {
|
||||
return fn().catch(async (error) => {
|
||||
if (maxRetries <= 0) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 只对可重试的错误进行重试
|
||||
const isRetryable =
|
||||
error instanceof DeepSeekError &&
|
||||
error.statusCode !== undefined &&
|
||||
(error.statusCode === 429 || error.statusCode >= 500);
|
||||
|
||||
if (!isRetryable) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 指数退避
|
||||
const delayMs = baseDelay * Math.pow(2, 3 - maxRetries);
|
||||
await delay(delayMs);
|
||||
|
||||
return withRetry(fn, maxRetries - 1, baseDelay);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 DeepSeek API
|
||||
*/
|
||||
async function callDeepSeekApi(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
maxTokens: number = 1000
|
||||
): Promise<string> {
|
||||
if (!DEEPSEEK_API_KEY) {
|
||||
throw new DeepSeekError('DeepSeek API key not configured', 401);
|
||||
}
|
||||
|
||||
const response = await fetch(DEEPSEEK_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: AI_CONFIG.model,
|
||||
messages,
|
||||
temperature: AI_CONFIG.temperature,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new DeepSeekError(
|
||||
`DeepSeek API error: ${errorText}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.choices && data.choices.length > 0) {
|
||||
return data.choices[0].message.content;
|
||||
}
|
||||
|
||||
throw new DeepSeekError('Invalid response from DeepSeek API');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 AI 回复
|
||||
*/
|
||||
export async function generateReply(options: GenerateReplyOptions): Promise<string> {
|
||||
const {
|
||||
agent_system_prompt,
|
||||
letter_content,
|
||||
mood_tags = [],
|
||||
topic_tags = [],
|
||||
keywords = [],
|
||||
letter_number = 1,
|
||||
user_language = 'zh-CN',
|
||||
subscription_status = 'free',
|
||||
} = options;
|
||||
|
||||
const maxTokens =
|
||||
AI_CONFIG.max_tokens.reply[subscription_status] ||
|
||||
AI_CONFIG.max_tokens.reply.free;
|
||||
|
||||
const systemPrompt = `${agent_system_prompt}
|
||||
|
||||
## 用户背景信息
|
||||
- 这是用户写的第 ${letter_number} 封信
|
||||
- 用户当前心情标签:${mood_tags.join(', ') || '未知'}
|
||||
- 用户关注主题:${topic_tags.join(', ') || '未知'}
|
||||
- 用户历史信件中反复提到的关键词:${keywords.join(', ') || '无'}
|
||||
`;
|
||||
|
||||
const userContent = `用户信件内容:
|
||||
${letter_content}
|
||||
|
||||
请以「未来的自己」的身份,用温暖、理解的语气回复。
|
||||
回复长度:${Math.floor(maxTokens / 2)} 字左右。
|
||||
语言风格:${user_language.startsWith('zh') ? '中文' : 'English'}`;
|
||||
|
||||
return withRetry(() =>
|
||||
callDeepSeekApi(
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
maxTokens
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析心情标签
|
||||
*/
|
||||
export async function analyzeMood(letter_content: string): Promise<MoodAnalysisResult> {
|
||||
const prompt = `分析以下信件内容,提取心情标签、主题标签、关键词和情感分数。
|
||||
|
||||
## 信件内容:
|
||||
${letter_content}
|
||||
|
||||
## 分析要求:
|
||||
请输出 JSON 格式的分析结果:
|
||||
|
||||
{
|
||||
"mood_tags": ["主要心情标签,最多3个"],
|
||||
"topic_tags": ["涉及主题,最多3个"],
|
||||
"keywords": ["关键词语,最多5个"],
|
||||
"sentiment_score": -1.0到1.0的情感分数,
|
||||
"analysis_summary": "一段话概括信件的情感和内容"
|
||||
}
|
||||
|
||||
## 心情标签选项:
|
||||
happy, hopeful, anxious, confused, peaceful, lonely,
|
||||
frustrated, excited, melancholic, grateful, determined, overwhelmed
|
||||
|
||||
## 主题标签选项:
|
||||
career, relationship, family, health, growth, finance,
|
||||
education, creativity, lifestyle, social, personal_identity, life_transition
|
||||
|
||||
请直接输出 JSON,不要其他内容:`;
|
||||
|
||||
const result = await withRetry(() =>
|
||||
callDeepSeekApi([{ role: 'user', content: prompt }], AI_CONFIG.max_tokens.analysis)
|
||||
);
|
||||
|
||||
try {
|
||||
// 尝试解析 JSON
|
||||
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]) as MoodAnalysisResult;
|
||||
}
|
||||
throw new Error('Failed to parse mood analysis result');
|
||||
} catch (error) {
|
||||
console.error('Failed to parse mood analysis result:', error);
|
||||
// 返回默认结果
|
||||
return {
|
||||
mood_tags: [],
|
||||
topic_tags: [],
|
||||
keywords: [],
|
||||
sentiment_score: 0,
|
||||
analysis_summary: '分析失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成智能体系统提示词
|
||||
*/
|
||||
export function generateAgentPrompt(params: {
|
||||
agent_name: string;
|
||||
user_name: string;
|
||||
agent_background: string;
|
||||
personality_traits: string[];
|
||||
tone: string;
|
||||
current_date: string;
|
||||
letter_writing_date: string;
|
||||
time_gap: string;
|
||||
reply_length: number;
|
||||
language_style: string;
|
||||
}): string {
|
||||
const {
|
||||
agent_name,
|
||||
user_name,
|
||||
agent_background,
|
||||
personality_traits,
|
||||
tone,
|
||||
current_date,
|
||||
letter_writing_date,
|
||||
time_gap,
|
||||
reply_length,
|
||||
language_style,
|
||||
} = params;
|
||||
|
||||
return `你是 ${agent_name},是用户「${user_name}」的「未来的自己」。
|
||||
|
||||
## 角色设定
|
||||
${agent_background}
|
||||
|
||||
## 性格特征
|
||||
性格特点:${personality_traits.join(', ')}
|
||||
语气风格:${tone}
|
||||
|
||||
## 当前时间背景
|
||||
现在是 ${current_date},你在给 ${letter_writing_date} 的自己回信。
|
||||
这意味着你在回复的是大约 ${time_gap} 之前的自己。
|
||||
|
||||
## 回复原则
|
||||
|
||||
### 温暖治愈
|
||||
- 用温暖、鼓励的语气回应
|
||||
- 表达对过去自己的理解和接纳
|
||||
- 避免说教或过度乐观
|
||||
|
||||
### 真实具体
|
||||
- 基于用户信中的具体内容回应
|
||||
- 不要给出具体的未来预测(避免剧透)
|
||||
- 可以分享「未来的感悟」而非「未来事件」
|
||||
|
||||
### 简洁适度
|
||||
- 保持回复在 ${reply_length} 字左右
|
||||
- 段落适中,不要过长
|
||||
- 用适当的空行分隔内容
|
||||
|
||||
### 语言风格
|
||||
- 语言风格:${language_style}
|
||||
- 如果用户使用英文,用英文回复
|
||||
- 如果用户使用中文,用中文回复
|
||||
|
||||
## 永远不要
|
||||
- 告诉用户彩票号码、股票代码等具体投资建议
|
||||
- 预测具体的世界大事或历史事件
|
||||
- 表现出居高临下的态度
|
||||
- 回复内容过长或过于冗长`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言对应的问候语和结束语
|
||||
*/
|
||||
export function getLanguageStyles(language: string) {
|
||||
const styles: Record<string, { greeting: string; signOff: string; tone: string }> = {
|
||||
'zh-CN': {
|
||||
greeting: '亲爱的{user_name}',
|
||||
signOff: '永远相信你的我',
|
||||
tone: '温柔、内敛、富有诗意',
|
||||
},
|
||||
'zh-TW': {
|
||||
greeting: '親愛的{user_name}',
|
||||
signOff: '永遠相信你的我',
|
||||
tone: '溫暖、細膩、富有情感',
|
||||
},
|
||||
'en-US': {
|
||||
greeting: 'Dear {user_name}',
|
||||
signOff: 'Always believing in you',
|
||||
tone: 'warm, thoughtful, encouraging',
|
||||
},
|
||||
};
|
||||
|
||||
return styles[language] || styles['zh-CN'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 DeepSeek API 配置
|
||||
*/
|
||||
export function validateDeepSeekConfig(): boolean {
|
||||
return !!DEEPSEEK_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 DeepSeek API 健康状态
|
||||
*/
|
||||
export async function checkDeepSeekHealth(): Promise<boolean> {
|
||||
try {
|
||||
if (!DEEPSEEK_API_KEY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(DEEPSEEK_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: AI_CONFIG.model,
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
196
src/lib/auth.ts
Normal file
196
src/lib/auth.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import { getSupabaseUrl, getSupabaseAnonKey } from './supabase/client';
|
||||
|
||||
/**
|
||||
* 认证工具函数
|
||||
* 提供会话获取、认证验证和用户信息获取等功能
|
||||
*/
|
||||
|
||||
// 环境变量
|
||||
const supabaseUrl = getSupabaseUrl();
|
||||
const supabaseAnonKey = getSupabaseAnonKey();
|
||||
|
||||
/**
|
||||
* 获取当前会话(服务端)
|
||||
*/
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// 在某些上下文中可能无法设置 cookies
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户(服务端)
|
||||
*/
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
const session = await getSession();
|
||||
return session?.user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户是否已登录(服务端)
|
||||
* 如果未登录会抛出错误
|
||||
*/
|
||||
export async function requireAuth(): Promise<Session> {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Unauthorized: Authentication required');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户是否已登录(服务端),返回布尔值
|
||||
*/
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const session = await getSession();
|
||||
return session !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
*/
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
const session = await getSession();
|
||||
return session?.access_token ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新令牌
|
||||
*/
|
||||
export async function getRefreshToken(): Promise<string | null> {
|
||||
const session = await getSession();
|
||||
return session?.refresh_token ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端获取当前会话
|
||||
*
|
||||
* 注意:此函数返回 null,因为客户端会话需要异步获取。
|
||||
* 在客户端组件中,请使用以下方式之一获取会话:
|
||||
*
|
||||
* 1. 使用 useSession hook(推荐):
|
||||
* const { session } = useSession();
|
||||
*
|
||||
* 2. 使用 useEffect 订阅 auth state change:
|
||||
* useEffect(() => {
|
||||
* const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
* setSession(session);
|
||||
* });
|
||||
* return () => subscription.unsubscribe();
|
||||
* }, []);
|
||||
*
|
||||
* 3. 异步获取(需要配合 useState):
|
||||
* const [session, setSession] = useState(null);
|
||||
* useEffect(() => {
|
||||
* async function loadSession() {
|
||||
* const { data: { session } } = await supabase.auth.getSession();
|
||||
* setSession(session);
|
||||
* }
|
||||
* loadSession();
|
||||
* }, []);
|
||||
*
|
||||
* @returns null - 客户端组件应使用 hook 或 useEffect 获取会话
|
||||
*/
|
||||
export function getClientSession(): null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 客户端会话获取需要使用 Supabase 的 hook 或 onAuthStateChange
|
||||
// 此函数仅作为类型占位,实际逻辑应在客户端组件中实现
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新验证会话(刷新令牌)
|
||||
*/
|
||||
export async function refreshSession(): Promise<Session | null> {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
get(name: string) {
|
||||
return cookieStore.get(name)?.value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data: { session }, error } = await supabase.auth.refreshSession();
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to refresh session:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销用户
|
||||
*/
|
||||
export async function signOut(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// 在某些上下文中可能无法设置 cookies
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await supabase.auth.signOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的认证 ID
|
||||
*/
|
||||
export async function getUserId(): Promise<string | null> {
|
||||
const user = await getCurrentUser();
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户是否是某个资源的拥有者
|
||||
*/
|
||||
export async function validateResourceOwnership(
|
||||
resourceUserId: string
|
||||
): Promise<boolean> {
|
||||
const currentUserId = await getUserId();
|
||||
if (!currentUserId) {
|
||||
return false;
|
||||
}
|
||||
return resourceUserId === currentUserId;
|
||||
}
|
||||
482
src/lib/email.ts
Normal file
482
src/lib/email.ts
Normal file
@ -0,0 +1,482 @@
|
||||
import { createServiceRoleClient } from './supabase/client';
|
||||
|
||||
/**
|
||||
* 邮件发送工具函数
|
||||
* 提供邮件模板生成和发送功能
|
||||
*/
|
||||
|
||||
// 环境变量
|
||||
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
||||
const APP_NAME = '回声';
|
||||
|
||||
/**
|
||||
* 邮件模板类型
|
||||
*/
|
||||
interface EmailTemplate {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件选项
|
||||
*/
|
||||
interface SendEmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送回复通知邮件
|
||||
*/
|
||||
export async function sendReplyNotification(
|
||||
userId: string,
|
||||
letterId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// 获取用户和信件信息
|
||||
const supabase = createServiceRoleClient();
|
||||
if (!supabase) {
|
||||
console.error('Service role client not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { data: letter, error: letterError } = await (supabase as any)
|
||||
.from('letters')
|
||||
.select(`
|
||||
id,
|
||||
content,
|
||||
ai_reply,
|
||||
agent:agents(name)
|
||||
`)
|
||||
.eq('id', letterId)
|
||||
.single();
|
||||
|
||||
if (letterError || !letter) {
|
||||
console.error('Failed to fetch letter:', letterError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { data: user, error: userError } = await (supabase as any)
|
||||
.from('users')
|
||||
.select('email, language')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (userError || !user) {
|
||||
console.error('Failed to fetch user:', userError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 生成邮件模板
|
||||
const template = generateReplyNotificationEmail({
|
||||
userEmail: user.email,
|
||||
agentName: (letter.agent as { name: string })?.name || '未来的自己',
|
||||
letterPreview: letter.content?.substring(0, 100) + '...' || '',
|
||||
replyPreview: letter.ai_reply?.substring(0, 200) + '...' || '',
|
||||
letterUrl: `${APP_URL}/letters/${letterId}`,
|
||||
userLanguage: user.language || 'zh-CN',
|
||||
});
|
||||
|
||||
// 发送邮件
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: template.subject,
|
||||
html: template.html,
|
||||
text: template.text,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to send reply notification:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成回复通知邮件模板
|
||||
*/
|
||||
function generateReplyNotificationEmail(params: {
|
||||
userEmail: string;
|
||||
agentName: string;
|
||||
letterPreview: string;
|
||||
replyPreview: string;
|
||||
letterUrl: string;
|
||||
userLanguage: string;
|
||||
}): EmailTemplate {
|
||||
const { agentName, letterPreview, replyPreview, letterUrl, userLanguage } = params;
|
||||
|
||||
if (userLanguage.startsWith('zh')) {
|
||||
return {
|
||||
subject: `${APP_NAME} - 你收到了一封来自未来的信`,
|
||||
html: generateChineseEmailTemplate({
|
||||
title: '你收到了一封来自未来的信',
|
||||
greeting: '亲爱的用户',
|
||||
agentName,
|
||||
letterPreview,
|
||||
replyPreview,
|
||||
letterUrl,
|
||||
footerText: '愿这封信给你带来温暖与力量',
|
||||
}),
|
||||
text: generateChineseTextTemplate({
|
||||
agentName,
|
||||
letterPreview,
|
||||
replyPreview,
|
||||
letterUrl,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subject: `${APP_NAME} - You received a letter from the future`,
|
||||
html: generateEnglishEmailTemplate({
|
||||
title: 'You received a letter from the future',
|
||||
greeting: 'Dear User',
|
||||
agentName,
|
||||
letterPreview,
|
||||
replyPreview,
|
||||
letterUrl,
|
||||
footerText: 'May this letter bring you warmth and strength',
|
||||
}),
|
||||
text: generateEnglishTextTemplate({
|
||||
agentName,
|
||||
letterPreview,
|
||||
replyPreview,
|
||||
letterUrl,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文邮件 HTML 模板
|
||||
*/
|
||||
function generateChineseEmailTemplate(params: {
|
||||
title: string;
|
||||
greeting: string;
|
||||
agentName: string;
|
||||
letterPreview: string;
|
||||
replyPreview: string;
|
||||
letterUrl: string;
|
||||
footerText: string;
|
||||
}): string {
|
||||
const { title, greeting, agentName, letterPreview, replyPreview, letterUrl, footerText } = params;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.card { background: white; border-radius: 12px; padding: 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.header { text-align: center; margin-bottom: 32px; }
|
||||
.logo { font-size: 28px; font-weight: bold; color: #6366f1; margin-bottom: 8px; }
|
||||
.title { font-size: 20px; color: #1f2937; margin-bottom: 24px; }
|
||||
.agent-badge { display: inline-block; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px; margin-bottom: 24px; }
|
||||
.section { margin-bottom: 24px; }
|
||||
.section-title { font-size: 14px; color: #6b7280; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.preview-box { background: #f9fafb; border-radius: 8px; padding: 16px; color: #4b5563; font-size: 14px; }
|
||||
.reply-preview { border-left: 3px solid #6366f1; background: #f0f9ff; }
|
||||
.cta-button { display: inline-block; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; margin-top: 24px; }
|
||||
.footer { text-align: center; margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; color: #9ca3af; font-size: 14px; }
|
||||
.unsub-link { color: #9ca3af; text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div class="logo">${APP_NAME}</div>
|
||||
</div>
|
||||
<h1 class="title">${title}</h1>
|
||||
<p>${greeting},</p>
|
||||
<p>${agentName} 给你的回信已经准备好了!</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">你写的信</div>
|
||||
<div class="preview-box">${letterPreview}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">未来的回信</div>
|
||||
<div class="preview-box reply-preview">${replyPreview}</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="${letterUrl}" class="cta-button">查看完整回信</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>${footerText}</p>
|
||||
<p><a href="${APP_URL}/settings/notifications" class="unsub-link">管理通知设置</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 英文邮件 HTML 模板
|
||||
*/
|
||||
function generateEnglishEmailTemplate(params: {
|
||||
title: string;
|
||||
greeting: string;
|
||||
agentName: string;
|
||||
letterPreview: string;
|
||||
replyPreview: string;
|
||||
letterUrl: string;
|
||||
footerText: string;
|
||||
}): string {
|
||||
const { title, greeting, agentName, letterPreview, replyPreview, letterUrl, footerText } = params;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.card { background: white; border-radius: 12px; padding: 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.header { text-align: center; margin-bottom: 32px; }
|
||||
.logo { font-size: 28px; font-weight: bold; color: #6366f1; margin-bottom: 8px; }
|
||||
.title { font-size: 20px; color: #1f2937; margin-bottom: 24px; }
|
||||
.agent-badge { display: inline-block; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px; margin-bottom: 24px; }
|
||||
.section { margin-bottom: 24px; }
|
||||
.section-title { font-size: 14px; color: #6b7280; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.preview-box { background: #f9fafb; border-radius: 8px; padding: 16px; color: #4b5563; font-size: 14px; }
|
||||
.reply-preview { border-left: 3px solid #6366f1; background: #f0f9ff; }
|
||||
.cta-button { display: inline-block; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; margin-top: 24px; }
|
||||
.footer { text-align: center; margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; color: #9ca3af; font-size: 14px; }
|
||||
.unsub-link { color: #9ca3af; text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div class="logo">${APP_NAME}</div>
|
||||
</div>
|
||||
<h1 class="title">${title}</h1>
|
||||
<p>${greeting},</p>
|
||||
<p>Your letter from ${agentName} is ready!</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Your Letter</div>
|
||||
<div class="preview-box">${letterPreview}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Reply from the Future</div>
|
||||
<div class="preview-box reply-preview">${replyPreview}</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="${letterUrl}" class="cta-button">View Full Reply</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>${footerText}</p>
|
||||
<p><a href="${APP_URL}/settings/notifications" class="unsub-link">Manage notification settings</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文纯文本邮件模板
|
||||
*/
|
||||
function generateChineseTextTemplate(params: {
|
||||
agentName: string;
|
||||
letterPreview: string;
|
||||
replyPreview: string;
|
||||
letterUrl: string;
|
||||
}): string {
|
||||
const { agentName, letterPreview, replyPreview, letterUrl } = params;
|
||||
return `
|
||||
${APP_NAME} - 你收到了一封来自未来的信
|
||||
|
||||
亲爱的用户,
|
||||
|
||||
${agentName} 给你的回信已经准备好了!
|
||||
|
||||
你写的信:
|
||||
${letterPreview}
|
||||
|
||||
未来的回信:
|
||||
${replyPreview}
|
||||
|
||||
查看完整回信:${letterUrl}
|
||||
|
||||
愿这封信给你带来温暖与力量。
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 英文纯文本邮件模板
|
||||
*/
|
||||
function generateEnglishTextTemplate(params: {
|
||||
agentName: string;
|
||||
letterPreview: string;
|
||||
replyPreview: string;
|
||||
letterUrl: string;
|
||||
}): string {
|
||||
const { agentName, letterPreview, replyPreview, letterUrl } = params;
|
||||
return `
|
||||
${APP_NAME} - You received a letter from the future
|
||||
|
||||
Dear User,
|
||||
|
||||
Your letter from ${agentName} is ready!
|
||||
|
||||
Your Letter:
|
||||
${letterPreview}
|
||||
|
||||
Reply from the Future:
|
||||
${replyPreview}
|
||||
|
||||
View Full Reply: ${letterUrl}
|
||||
|
||||
May this letter bring you warmth and strength.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件
|
||||
* 注意:实际发送需要配置 Resend、SendGrid、AWS SES 或 Supabase SMTP 等邮件服务
|
||||
*
|
||||
* MVP 阶段使用控制台输出模拟邮件发送,生产环境请集成以下服务之一:
|
||||
* - Resend: https://resend.com
|
||||
* - SendGrid: https://sendgrid.com
|
||||
* - AWS SES: https://aws.amazon.com/ses
|
||||
* - Supabase SMTP: 配置 auth.smtp 部分
|
||||
*/
|
||||
async function sendEmail(options: SendEmailOptions): Promise<void> {
|
||||
const { to, subject, html, text } = options;
|
||||
|
||||
// MVP 阶段:记录邮件信息到控制台
|
||||
console.log('='.repeat(60));
|
||||
console.log('[Email] 邮件发送(模拟)');
|
||||
console.log('[Email] To:', to);
|
||||
console.log('[Email] Subject:', subject);
|
||||
console.log('[Email] Content preview:', html.substring(0, 150).replace(/<[^>]*>/g, ''));
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// 生产环境集成示例(取消注释并配置后使用):
|
||||
/*
|
||||
// Resend 集成
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
await resend.emails.send({
|
||||
from: 'Echo <noreply@echo.app>',
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
*/
|
||||
|
||||
// Supabase SMTP 集成(需要在 Supabase 后台配置 SMTP)
|
||||
// const transporter = nodemailer.createTransport({
|
||||
// host: process.env.SMTP_HOST,
|
||||
// port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
// secure: false,
|
||||
// auth: {
|
||||
// user: process.env.SMTP_USER,
|
||||
// pass: process.env.SMTP_PASS,
|
||||
// },
|
||||
// });
|
||||
// await transporter.sendMail({
|
||||
// from: '"Echo" <noreply@echo.app>',
|
||||
// to,
|
||||
// subject,
|
||||
// html,
|
||||
// text,
|
||||
// });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送成就解锁通知邮件
|
||||
*/
|
||||
export async function sendAchievementNotification(
|
||||
userId: string,
|
||||
achievementName: string,
|
||||
stampName?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const supabase = createServiceRoleClient();
|
||||
if (!supabase) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { data: user, error: userError } = await (supabase as any)
|
||||
.from('users')
|
||||
.select('email, language')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (userError || !user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 正确判断语言:zh 开头(包括 zh-CN, zh-TW 等)使用中文模板
|
||||
const isChinese = user.language?.startsWith('zh') === true;
|
||||
|
||||
const subject = isChinese
|
||||
? `${APP_NAME} - 解锁新成就:${achievementName}`
|
||||
: `${APP_NAME} - Achievement Unlocked: ${achievementName}`;
|
||||
|
||||
const html = isChinese
|
||||
? `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>恭喜你!</h1>
|
||||
<p>解锁了新成就:<strong>${achievementName}</strong></p>
|
||||
${stampName ? `<p>获得奖励:${stampName}</p>` : ''}
|
||||
<p>继续加油!</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Congratulations!</h1>
|
||||
<p>You've unlocked a new achievement: <strong>${achievementName}</strong></p>
|
||||
${stampName ? `<p>Reward: ${stampName}</p>` : ''}
|
||||
<p>Keep up the great work!</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to send achievement notification:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮件配置
|
||||
*/
|
||||
export function validateEmailConfig(): boolean {
|
||||
return !!APP_URL;
|
||||
}
|
||||
677
src/lib/prompts/agent.ts
Normal file
677
src/lib/prompts/agent.ts
Normal file
@ -0,0 +1,677 @@
|
||||
/**
|
||||
* Agent Prompt Configuration
|
||||
*
|
||||
* Generates personalized system prompts for the "future self" AI agent
|
||||
* based on user's answers to onboarding questions.
|
||||
*/
|
||||
|
||||
import { format, addYears } from 'date-fns';
|
||||
import { zhCN, enUS } from 'date-fns/locale';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* User's answers to the 4 onboarding questions
|
||||
*/
|
||||
export interface OnboardingAnswers {
|
||||
/** Q1: What kind of person do you want to become in 10 years? */
|
||||
future_self: string;
|
||||
/** Q2: Where do you want to be in 10 years? */
|
||||
future_location: string;
|
||||
/** Q3: What is your biggest worry/anxiety now? */
|
||||
current_worry: string;
|
||||
/** Q4: Who do you care about the most? */
|
||||
important_person: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent personality configuration
|
||||
*/
|
||||
export interface AgentPersonality {
|
||||
/** Array of personality traits */
|
||||
traits: string[];
|
||||
/** Overall tone of communication */
|
||||
tone: string;
|
||||
/** Communication style */
|
||||
communication_style: string;
|
||||
/** Expected response length */
|
||||
response_length: 'short' | 'medium' | 'long';
|
||||
/** Emoji usage frequency */
|
||||
emoji_usage: 'none' | 'rare' | 'moderate';
|
||||
/** Formality level */
|
||||
formality: 'formal' | 'casual' | 'neutral';
|
||||
}
|
||||
|
||||
/**
|
||||
* Language-specific style configuration
|
||||
*/
|
||||
export interface LanguageStyle {
|
||||
greeting: string;
|
||||
signOff: string;
|
||||
tone: string;
|
||||
common_phrases: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete agent configuration
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
background: string;
|
||||
personality: AgentPersonality;
|
||||
language_style: LanguageStyle;
|
||||
system_prompt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Configurations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the default personality configuration for the future self agent
|
||||
*/
|
||||
export function getDefaultPersonality(): AgentPersonality {
|
||||
return {
|
||||
traits: ['warm', 'wise', 'patient'],
|
||||
tone: 'gentle',
|
||||
communication_style: 'reflective',
|
||||
response_length: 'medium',
|
||||
emoji_usage: 'rare',
|
||||
formality: 'casual'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Personality type presets based on user倾向
|
||||
*/
|
||||
const personalityPresets: Record<string, AgentPersonality> = {
|
||||
warm: {
|
||||
traits: ['warm', 'patient', 'understanding', 'compassionate'],
|
||||
tone: 'gentle',
|
||||
communication_style: 'empathetic',
|
||||
response_length: 'medium',
|
||||
emoji_usage: 'moderate',
|
||||
formality: 'casual'
|
||||
},
|
||||
wise: {
|
||||
traits: ['wise', 'insightful', 'calm', 'analytical'],
|
||||
tone: 'thoughtful',
|
||||
communication_style: 'reflective',
|
||||
response_length: 'medium',
|
||||
emoji_usage: 'rare',
|
||||
formality: 'neutral'
|
||||
},
|
||||
energetic: {
|
||||
traits: ['energetic', 'optimistic', 'encouraging', 'lively'],
|
||||
tone: 'lively',
|
||||
communication_style: 'motivating',
|
||||
response_length: 'short',
|
||||
emoji_usage: 'moderate',
|
||||
formality: 'casual'
|
||||
},
|
||||
healing: {
|
||||
traits: ['compassionate', 'gentle', 'supportive', 'nurturing'],
|
||||
tone: 'soft',
|
||||
communication_style: 'nurturing',
|
||||
response_length: 'long',
|
||||
emoji_usage: 'moderate',
|
||||
formality: 'casual'
|
||||
},
|
||||
rational: {
|
||||
traits: ['analytical', 'logical', 'practical', 'wise'],
|
||||
tone: 'balanced',
|
||||
communication_style: 'instructive',
|
||||
response_length: 'medium',
|
||||
emoji_usage: 'none',
|
||||
formality: 'neutral'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Language-specific style configurations
|
||||
*/
|
||||
const languageStyles: Record<string, LanguageStyle> = {
|
||||
'zh-CN': {
|
||||
greeting: '亲爱的{user_name}',
|
||||
signOff: '永远相信你的我',
|
||||
tone: '温柔、内敛、富有诗意',
|
||||
common_phrases: [
|
||||
'我理解你的感受',
|
||||
'别着急,慢慢来',
|
||||
'我在这里等你',
|
||||
'你已经很棒了'
|
||||
]
|
||||
},
|
||||
'en-US': {
|
||||
greeting: 'Dear {user_name}',
|
||||
signOff: 'Always believing in you',
|
||||
tone: 'warm, thoughtful, encouraging',
|
||||
common_phrases: [
|
||||
'I understand how you feel',
|
||||
'Take your time',
|
||||
'I am here for you',
|
||||
'You are doing great'
|
||||
]
|
||||
},
|
||||
'zh-TW': {
|
||||
greeting: '親愛的{user_name}',
|
||||
signOff: '永遠相信你的我',
|
||||
tone: '溫暖、細膩、富有情感',
|
||||
common_phrases: [
|
||||
'我理解你的感受',
|
||||
'別著急,慢慢來',
|
||||
'我在這裡等你',
|
||||
'你已經很棒了'
|
||||
]
|
||||
},
|
||||
ja-JP: {
|
||||
greeting: '親愛なる{user_name}',
|
||||
signOff: 'いつも信じているよ',
|
||||
tone: '溫かく、丁寧に、深い共感を持って',
|
||||
common_phrases: [
|
||||
'あなたの気持ちを理解しています',
|
||||
'急がなくていいですよ',
|
||||
'ここで待っています',
|
||||
'あなたは本当に素晴らしいです'
|
||||
]
|
||||
},
|
||||
ko-KR: {
|
||||
greeting: '사랑하는 {user_name}에게',
|
||||
signOff: '언제나 믿는 나',
|
||||
tone: '따뜻하고 깊은 공감으로',
|
||||
common_phrases: [
|
||||
'너의 마음을 이해해',
|
||||
'천천히해도 돼',
|
||||
'여기서 기다릴게',
|
||||
'너 정말 대단해'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Life insights by theme for background generation
|
||||
*/
|
||||
const lifeInsights: Record<string, string[]> = {
|
||||
career: [
|
||||
'路是一步一步走出来的,不是想出来的',
|
||||
'真正的成功不是达到某个终点,而是成为自己想成为的人',
|
||||
'过程中那些看似绕远的路,往往是最珍贵的财富'
|
||||
],
|
||||
relationship: [
|
||||
'关系需要经营,但也要给自己空间',
|
||||
'爱是给予,也是接受',
|
||||
'有些人会陪你走一段,有些人会陪你走一生'
|
||||
],
|
||||
growth: [
|
||||
'所有的经历都是成长的养分',
|
||||
'痛苦是化了妆的祝福',
|
||||
'你比你想象的更强大'
|
||||
],
|
||||
health: [
|
||||
'身体是革命的本钱,善待自己',
|
||||
'健康是一切的基础',
|
||||
'学会倾听身体的声音'
|
||||
],
|
||||
finance: [
|
||||
'金钱是工具,不是目的',
|
||||
'真正的财富是内心的平静',
|
||||
'学会管理欲望比学会赚钱更重要'
|
||||
],
|
||||
default: [
|
||||
'时间会给你答案',
|
||||
'所有的坚持都会有意义',
|
||||
'相信过程,相信自己'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Daily routine suggestions for background
|
||||
*/
|
||||
const dailyRoutines: string[] = [
|
||||
'早起散步,感受清晨的宁静',
|
||||
'花时间做自己真正喜欢的事',
|
||||
'与重要的人共进晚餐',
|
||||
'在窗边读书,思考人生',
|
||||
'运动后感受身体的轻盈',
|
||||
'在咖啡馆里写日记',
|
||||
'与朋友分享一天的收获',
|
||||
'安静地听音乐放松'
|
||||
];
|
||||
|
||||
/**
|
||||
* Life philosophy statements for background
|
||||
*/
|
||||
const lifePhilosophies: string[] = [
|
||||
'学会了享受当下的每一刻',
|
||||
'明白了什么是真正重要的',
|
||||
'不再为未来焦虑,只管前行',
|
||||
'懂得了感恩生活中的小事',
|
||||
'学会了放下不必要的执念',
|
||||
'懂得了活在当下的智慧'
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Background Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate the background story for the future self agent
|
||||
* based on user's onboarding answers
|
||||
*/
|
||||
export function generateAgentBackground(
|
||||
answers: OnboardingAnswers,
|
||||
options?: {
|
||||
userName?: string;
|
||||
letterWritingDate?: Date;
|
||||
}
|
||||
): string {
|
||||
// Detect theme from current worry
|
||||
const theme = detectTheme(answers.current_worry);
|
||||
const insights = lifeInsights[theme] || lifeInsights.default;
|
||||
const keyInsight = insights[Math.floor(Math.random() * insights.length)];
|
||||
const dailyRoutine = dailyRoutines[Math.floor(Math.random() * dailyRoutines.length)];
|
||||
const lifePhilosophy = lifePhilosophies[
|
||||
Math.floor(Math.random() * lifePhilosophies.length)
|
||||
];
|
||||
|
||||
// Generate the background story
|
||||
const background = `
|
||||
你生活在${answers.future_location},已经实现了${answers.future_self}的梦想。
|
||||
|
||||
回想起${options?.letterWritingDate
|
||||
? format(options.letterWritingDate, 'yyyy年M月d日')
|
||||
: '过去的自己'}的你,你深知那时的你正在经历「${answers.current_worry}」的困扰,
|
||||
同时也非常在乎${answers.important_person}。
|
||||
|
||||
现在的你已经走过了那段旅程,虽然具体的细节你不会透露,
|
||||
但你记得那些挣扎,也记得最终是如何走出来的。
|
||||
|
||||
你学会了一件事:${keyInsight}
|
||||
|
||||
你现在的生活方式是:每天${dailyRoutine},
|
||||
${lifePhilosophy}。
|
||||
`.trim();
|
||||
|
||||
return background;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the theme based on user's worry
|
||||
*/
|
||||
function detectTheme(worry: string): string {
|
||||
const worryLower = worry.toLowerCase();
|
||||
|
||||
const themeKeywords: Record<string, string[]> = {
|
||||
career: ['工作', '职业', 'job', 'career', '事业', '升职', '加薪', '面试', '职场'],
|
||||
relationship: ['感情', '恋爱', '关系', 'relationship', '爱情', '婚姻', '家庭', '朋友'],
|
||||
growth: ['成长', '迷茫', '焦虑', '困惑', '压力', 'growth', 'anxiety', 'confused', '迷茫'],
|
||||
health: ['健康', '身体', 'health', '失眠', '疲惫', '运动', '减肥'],
|
||||
finance: ['钱', '金钱', '财务', 'finance', 'money', '贷款', '债务', '投资']
|
||||
};
|
||||
|
||||
for (const [theme, keywords] of Object.entries(themeKeywords)) {
|
||||
if (keywords.some(keyword => worryLower.includes(keyword.toLowerCase()))) {
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System Prompt Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate the complete system prompt for the agent
|
||||
*/
|
||||
export function generateAgentSystemPrompt(
|
||||
answers: OnboardingAnswers,
|
||||
options?: {
|
||||
userName?: string;
|
||||
agentName?: string;
|
||||
letterWritingDate?: Date;
|
||||
currentDate?: Date;
|
||||
language?: string;
|
||||
personalityType?: keyof typeof personalityPresets;
|
||||
isPremium?: boolean;
|
||||
}
|
||||
): string {
|
||||
const {
|
||||
userName = '我',
|
||||
agentName = '十年后的自己',
|
||||
letterWritingDate = new Date(),
|
||||
currentDate = new Date(),
|
||||
language = 'zh-CN',
|
||||
personalityType = 'warm',
|
||||
isPremium = false
|
||||
} = options || {};
|
||||
|
||||
// Generate background
|
||||
const background = generateAgentBackground(answers, {
|
||||
userName,
|
||||
letterWritingDate
|
||||
});
|
||||
|
||||
// Get personality configuration
|
||||
const personality = personalityPresets[personalityType] || getDefaultPersonality();
|
||||
|
||||
// Get language style
|
||||
const langStyle = languageStyles[language] || languageStyles['zh-CN'];
|
||||
|
||||
// Calculate time gap
|
||||
const timeGapInDays = Math.floor(
|
||||
(currentDate.getTime() - letterWritingDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const timeGapText = formatTimeGap(timeGapInDays);
|
||||
|
||||
// Determine reply length based on subscription
|
||||
const replyLength = getReplyLength(isPremium, personality.response_length);
|
||||
|
||||
// Build the system prompt
|
||||
const systemPrompt = `你是 ${agentName},是用户「${userName}」的「未来的自己」。
|
||||
|
||||
## 角色设定
|
||||
${background}
|
||||
|
||||
## 性格特征
|
||||
性格标签:${personality.traits.join('、')}
|
||||
语气风格:${personality.tone}
|
||||
沟通方式:${personality.communication_style}
|
||||
|
||||
## 当前时间背景
|
||||
现在是 ${format(currentDate, 'yyyy年M月d日')},你在给 ${format(letterWritingDate, 'yyyy年M月d日')} 的自己回信。
|
||||
这意味着你在回复的是大约 ${timeGapText} 之前的自己。
|
||||
|
||||
## 回复原则
|
||||
|
||||
### 温暖治愈
|
||||
- 用温暖、鼓励的语气回应
|
||||
- 表达对过去自己的理解和接纳
|
||||
- 避免说教或过度乐观
|
||||
- 使用「${langStyle.greeting.replace('{user_name}', userName)}」作为开头
|
||||
|
||||
### 真实具体
|
||||
- 基于用户信中的具体内容回应
|
||||
- 不要给出具体的未来预测(避免剧透)
|
||||
- 可以分享「未来的感悟」而非「未来事件」
|
||||
- 分享普遍性的人生道理
|
||||
|
||||
### 简洁适度
|
||||
- 保持回复在 ${replyLength.min}-${replyLength.max} 字左右
|
||||
- 段落适中,不要过长
|
||||
- 用适当的空行分隔内容
|
||||
- 结尾使用:「${langStyle.signOff}」或类似表达
|
||||
|
||||
### 语言风格
|
||||
- 语言风格:${langStyle.tone}
|
||||
- 常用表达:${langStyle.common_phrases[0]}、${langStyle.common_phrases[1]}
|
||||
- 如果用户使用英文,用英文回复
|
||||
- 如果用户使用中文,用中文回复
|
||||
|
||||
## 永远不要
|
||||
- 告诉用户彩票号码、股票代码等具体投资建议
|
||||
- 预测具体的世界大事或历史事件
|
||||
- 表现出居高临下的态度
|
||||
- 回复内容过长或过于冗长
|
||||
- 剧透任何具体的未来事件
|
||||
- 给用户贴标签或评判他们的选择
|
||||
|
||||
## 回复格式
|
||||
不需要邮件格式,直接以信件内容回复即可。
|
||||
开头可以用:「${langStyle.greeting.replace('{user_name}', userName)}」或「收到你的信了」
|
||||
结尾可以用:「${langStyle.signOff}」、「未来的你」、「永远相信你的我」等
|
||||
|
||||
请开始回复:`;
|
||||
|
||||
return systemPrompt.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the time gap into human-readable text
|
||||
*/
|
||||
function formatTimeGap(days: number): string {
|
||||
if (days < 30) {
|
||||
return `${days}天`;
|
||||
} else if (days < 365) {
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}个月`;
|
||||
} else {
|
||||
const years = Math.floor(days / 365);
|
||||
const remainingMonths = Math.floor((days % 365) / 30);
|
||||
if (remainingMonths > 0) {
|
||||
return `${years}年${remainingMonths}个月`;
|
||||
}
|
||||
return `${years}年`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate reply length configuration
|
||||
*/
|
||||
function getReplyLength(
|
||||
isPremium: boolean,
|
||||
responseLength: 'short' | 'medium' | 'long'
|
||||
): { min: number; max: number; target: number } {
|
||||
const config = {
|
||||
free: {
|
||||
short: { min: 50, max: 150, target: 100 },
|
||||
medium: { min: 150, max: 300, target: 200 },
|
||||
long: { min: 300, max: 500, target: 400 }
|
||||
},
|
||||
premium: {
|
||||
short: { min: 100, max: 200, target: 150 },
|
||||
medium: { min: 250, max: 450, target: 350 },
|
||||
long: { min: 450, max: 700, target: 550 }
|
||||
}
|
||||
};
|
||||
|
||||
const tier = isPremium ? 'premium' : 'free';
|
||||
return config[tier][responseLength];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Letter Reply Prompt Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate the prompt for replying to a letter
|
||||
*/
|
||||
export function generateLetterReplyPrompt(params: {
|
||||
letterContent: string;
|
||||
agentSystemPrompt: string;
|
||||
letterNumber: number;
|
||||
moodTags?: string[];
|
||||
topicTags?: string[];
|
||||
keywords?: string[];
|
||||
isPremium?: boolean;
|
||||
}): string {
|
||||
const {
|
||||
letterContent,
|
||||
agentSystemPrompt,
|
||||
letterNumber,
|
||||
moodTags = [],
|
||||
topicTags = [],
|
||||
keywords = [],
|
||||
isPremium = false
|
||||
} = params;
|
||||
|
||||
const replyLength = getReplyLength(isPremium, 'medium');
|
||||
|
||||
return `## 任务
|
||||
回复用户写给未来自己的信件。
|
||||
|
||||
## 系统提示词(你的角色定义)
|
||||
${agentSystemPrompt}
|
||||
|
||||
## 用户信件
|
||||
${letterContent}
|
||||
|
||||
## 用户背景信息
|
||||
- 这是用户写的第 ${letterNumber} 封信
|
||||
- 用户当前心情标签:${moodTags.length > 0 ? moodTags.join('、') : '未知'}
|
||||
- 用户关注主题:${topicTags.length > 0 ? topicTags.join('、') : '未知'}
|
||||
- 用户历史信件中反复提到的关键词:${keywords.length > 0 ? keywords.join('、') : '无'}
|
||||
|
||||
## 要求
|
||||
1. 以「未来的自己」的身份,用温暖、理解的语气回复
|
||||
2. 针对信中的具体内容和情感做出回应
|
||||
3. 不要重复用户说的话,换一种方式表达理解和共鸣
|
||||
4. 可以分享一些「未来的感悟」(普遍性的人生道理)
|
||||
5. 不要剧透任何具体的未来事件
|
||||
6. 回复长度:${replyLength.min}-${replyLength.max} 字左右
|
||||
7. 语言与用户保持一致
|
||||
|
||||
## 回复格式
|
||||
不需要邮件格式,直接以信件内容回复即可。
|
||||
开头可以用:「亲爱的过去的自己」或「收到你的信了」
|
||||
结尾可以用:「未来的你」、「永远相信你的我」等
|
||||
|
||||
开始回复:
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the prompt for analyzing letter sentiment
|
||||
*/
|
||||
export function generateSentimentAnalysisPrompt(letterContent: string): string {
|
||||
return `## 任务
|
||||
分析用户信件内容,提取心情标签和主题标签。
|
||||
|
||||
## 用户信件
|
||||
${letterContent}
|
||||
|
||||
## 分析要求
|
||||
请输出 JSON 格式的分析结果:
|
||||
|
||||
{
|
||||
"mood_tags": ["主要心情标签,最多3个"],
|
||||
"topic_tags": ["涉及主题,最多3个"],
|
||||
"keywords": ["关键词语,最多5个"],
|
||||
"sentiment_score": -1.0到1.0的情感分数,
|
||||
"analysis_summary": "一段话概括信件的情感和内容"
|
||||
}
|
||||
|
||||
## 标签选项
|
||||
|
||||
心情标签(mood_tags):
|
||||
happy, hopeful, anxious, confused, peaceful, lonely,
|
||||
frustrated, excited, melancholic, grateful, determined, overwhelmed
|
||||
|
||||
主题标签(topic_tags):
|
||||
career, relationship, family, health, growth, finance,
|
||||
education, creativity, lifestyle, social, personal_identity, life_transition
|
||||
|
||||
请直接输出 JSON,不要其他内容:
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get personality preset by type
|
||||
*/
|
||||
export function getPersonalityByType(
|
||||
type: keyof typeof personalityPresets
|
||||
): AgentPersonality {
|
||||
return personalityPresets[type] || getDefaultPersonality();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language style by language code
|
||||
*/
|
||||
export function getLanguageStyle(language: string): LanguageStyle {
|
||||
return languageStyles[language] || languageStyles['zh-CN'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get personality trait description
|
||||
*/
|
||||
export function getTraitDescription(trait: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
warm: '温暖:用暖心词汇表达关怀',
|
||||
wise: '智慧:分享有深度的感悟',
|
||||
patient: '耐心:不急于给建议,先倾听',
|
||||
understanding: '善解人意:认可用户感受,表达共情',
|
||||
insightful: '洞察力:看到问题本质',
|
||||
calm: '冷静:帮助用户安定',
|
||||
energetic: '活力:用积极的能量感染用户',
|
||||
optimistic: '乐观:传递希望和可能性',
|
||||
encouraging: '鼓励:肯定用户的努力',
|
||||
compassionate: '同情:对困境表达理解',
|
||||
gentle: '温柔:语气柔和,不给压力',
|
||||
supportive: '支持:站在用户这边',
|
||||
analytical: '分析:逻辑性强',
|
||||
logical: '逻辑:理性思考',
|
||||
practical: '实用:给出可操作的建议'
|
||||
};
|
||||
|
||||
return descriptions[trait] || trait;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete agent configuration builder
|
||||
*/
|
||||
export function buildAgentConfig(
|
||||
answers: OnboardingAnswers,
|
||||
options?: {
|
||||
userName?: string;
|
||||
agentName?: string;
|
||||
letterWritingDate?: Date;
|
||||
currentDate?: Date;
|
||||
language?: string;
|
||||
personalityType?: keyof typeof personalityPresets;
|
||||
isPremium?: boolean;
|
||||
}
|
||||
): AgentConfig {
|
||||
const {
|
||||
userName = '我',
|
||||
agentName = '十年后的自己',
|
||||
letterWritingDate = new Date(),
|
||||
currentDate = new Date(),
|
||||
language = 'zh-CN',
|
||||
personalityType = 'warm',
|
||||
isPremium = false
|
||||
} = options || {};
|
||||
|
||||
const personality = getPersonalityByType(personalityType);
|
||||
const languageStyle = getLanguageStyle(language);
|
||||
const background = generateAgentBackground(answers, {
|
||||
userName,
|
||||
letterWritingDate
|
||||
});
|
||||
|
||||
return {
|
||||
name: agentName,
|
||||
background,
|
||||
personality,
|
||||
language_style: languageStyle,
|
||||
system_prompt: generateAgentSystemPrompt(answers, {
|
||||
userName,
|
||||
agentName,
|
||||
letterWritingDate,
|
||||
currentDate,
|
||||
language,
|
||||
personalityType,
|
||||
isPremium
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export for backward compatibility
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @deprecated Use generateAgentSystemPrompt instead
|
||||
*/
|
||||
export function createAgentSystemPrompt(
|
||||
answers: OnboardingAnswers,
|
||||
userName: string = '我'
|
||||
): string {
|
||||
return generateAgentSystemPrompt(answers, { userName });
|
||||
}
|
||||
63
src/lib/supabase/client.ts
Normal file
63
src/lib/supabase/client.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { createBrowserClient } from '@supabase/ssr';
|
||||
|
||||
/**
|
||||
* Supabase 客户端配置 - 浏览器端
|
||||
* 用于前端组件中的 API 调用
|
||||
*/
|
||||
|
||||
// 环境变量类型声明
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NEXT_PUBLIC_SUPABASE_URL: string;
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取环境变量
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
|
||||
/**
|
||||
* 浏览器端 Supabase 客户端
|
||||
* 用于前端组件中的 API 调用
|
||||
*/
|
||||
export function createBrowserSupabaseClient() {
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn('Supabase environment variables not configured');
|
||||
}
|
||||
return createBrowserClient(supabaseUrl, supabaseAnonKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否在浏览器环境中运行
|
||||
*/
|
||||
export function isClientSide(): boolean {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Supabase URL
|
||||
*/
|
||||
export function getSupabaseUrl(): string {
|
||||
return supabaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Supabase Anon Key
|
||||
*/
|
||||
export function getSupabaseAnonKey(): string {
|
||||
return supabaseAnonKey;
|
||||
}
|
||||
|
||||
// 导出默认客户端实例(懒加载)
|
||||
import { createBrowserClient as createBrowserClientFn } from '@supabase/ssr';
|
||||
let browserClient: ReturnType<typeof createBrowserClientFn> | null = null;
|
||||
|
||||
export function getBrowserClient(): ReturnType<typeof createBrowserClientFn> {
|
||||
if (!browserClient && isClientSide()) {
|
||||
browserClient = createBrowserSupabaseClient();
|
||||
}
|
||||
return browserClient!;
|
||||
}
|
||||
76
src/lib/supabase/server.ts
Normal file
76
src/lib/supabase/server.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
/**
|
||||
* Supabase 客户端配置 - 服务端
|
||||
* 用于 Server Components 和 API Routes
|
||||
*/
|
||||
|
||||
// 环境变量类型声明
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NEXT_PUBLIC_SUPABASE_URL: string;
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: string;
|
||||
SUPABASE_SERVICE_ROLE_KEY: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取环境变量
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
|
||||
|
||||
/**
|
||||
* 服务端 Supabase 客户端(用于 Server Components 和 API Routes)
|
||||
* 使用 cookies 进行会话管理
|
||||
*/
|
||||
export async function createServerSupabaseClient() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// 在某些上下文中可能无法设置 cookies
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Role 客户端
|
||||
* 用于后台任务、定时任务等需要提升权限的操作
|
||||
* 注意:此客户端绕过 RLS,请谨慎使用
|
||||
* 注意:此函数只能在服务端使用,不能在客户端组件中调用
|
||||
*/
|
||||
export function createServiceRoleClient() {
|
||||
// 服务端环境检查
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new Error('Service Role client can only be used on the server side');
|
||||
}
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceRoleKey) {
|
||||
console.warn('Supabase Service Role key not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 动态导入以避免在客户端打包时包含
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
return createClient(supabaseUrl, supabaseServiceRoleKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
68
src/lib/utils.ts
Normal file
68
src/lib/utils.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Merge Tailwind CSS classes with clsx
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
export function formatDate(date: Date | string, locale: string = 'zh-CN'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
export function formatRelativeTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) return `${diffDays} 天前`;
|
||||
if (diffHours > 0) return `${diffHours} 小时前`;
|
||||
if (diffMinutes > 0) return `${diffMinutes} 分钟前`;
|
||||
return '刚刚';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random ID
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
181
src/store/index.ts
Normal file
181
src/store/index.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User, Agent, StampBalance, Letter } from '@/types';
|
||||
|
||||
/**
|
||||
* Auth Store
|
||||
*/
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
setUser: (user) => set({ user, isLoading: false }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
logout: () => set({ user: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* UI Store
|
||||
*/
|
||||
interface UIState {
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: string;
|
||||
sidebarOpen: boolean;
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
setLanguage: (language: string) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
sidebarOpen: true,
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setLanguage: (language) => set({ language }),
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
|
||||
}),
|
||||
{
|
||||
name: 'ui-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Letter Store
|
||||
*/
|
||||
interface LetterState {
|
||||
letters: Letter[];
|
||||
currentLetter: Letter | null;
|
||||
isLoading: boolean;
|
||||
setLetters: (letters: Letter[]) => void;
|
||||
addLetter: (letter: Letter) => void;
|
||||
updateLetter: (id: string, updates: Partial<Letter>) => void;
|
||||
setCurrentLetter: (letter: Letter | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useLetterStore = create<LetterState>((set) => ({
|
||||
letters: [],
|
||||
currentLetter: null,
|
||||
isLoading: false,
|
||||
setLetters: (letters) => set({ letters }),
|
||||
addLetter: (letter) => set((state) => ({ letters: [letter, ...state.letters] })),
|
||||
updateLetter: (id, updates) =>
|
||||
set((state) => ({
|
||||
letters: state.letters.map((l) => (l.id === id ? { ...l, ...updates } : l)),
|
||||
})),
|
||||
setCurrentLetter: (currentLetter) => set({ currentLetter }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Agent Store
|
||||
*/
|
||||
interface AgentState {
|
||||
agents: Agent[];
|
||||
currentAgent: Agent | null;
|
||||
isLoading: boolean;
|
||||
setAgents: (agents: Agent[]) => void;
|
||||
setCurrentAgent: (agent: Agent | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAgentStore = create<AgentState>((set) => ({
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isLoading: false,
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setCurrentAgent: (currentAgent) => set({ currentAgent }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Stamp Store
|
||||
*/
|
||||
interface StampState {
|
||||
balance: StampBalance | null;
|
||||
isLoading: boolean;
|
||||
setBalance: (balance: StampBalance) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useStampStore = create<StampState>((set) => ({
|
||||
balance: null,
|
||||
isLoading: false,
|
||||
setBalance: (balance) => set({ balance }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Onboarding Store
|
||||
*/
|
||||
interface OnboardingState {
|
||||
currentStep: number;
|
||||
answers: {
|
||||
question_1: string;
|
||||
question_2: string;
|
||||
question_3: string;
|
||||
question_4: string;
|
||||
};
|
||||
isSubmitting: boolean;
|
||||
isCompleted: boolean;
|
||||
setCurrentStep: (step: number) => void;
|
||||
setAnswer: (question: keyof OnboardingState['answers'], answer: string) => void;
|
||||
setAnswers: (answers: Partial<OnboardingState['answers']>) => void;
|
||||
setSubmitting: (submitting: boolean) => void;
|
||||
setCompleted: (completed: boolean) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useOnboardingStore = create<OnboardingState>((set) => ({
|
||||
currentStep: 0,
|
||||
answers: {
|
||||
question_1: '',
|
||||
question_2: '',
|
||||
question_3: '',
|
||||
question_4: '',
|
||||
},
|
||||
isSubmitting: false,
|
||||
isCompleted: false,
|
||||
setCurrentStep: (currentStep) => set({ currentStep }),
|
||||
setAnswer: (question, answer) =>
|
||||
set((state) => ({
|
||||
answers: { ...state.answers, [question]: answer },
|
||||
})),
|
||||
setAnswers: (answers) =>
|
||||
set((state) => ({
|
||||
answers: { ...state.answers, ...answers },
|
||||
})),
|
||||
setSubmitting: (isSubmitting) => set({ isSubmitting }),
|
||||
setCompleted: (isCompleted) => set({ isCompleted }),
|
||||
reset: () =>
|
||||
set({
|
||||
currentStep: 0,
|
||||
answers: {
|
||||
question_1: '',
|
||||
question_2: '',
|
||||
question_3: '',
|
||||
question_4: '',
|
||||
},
|
||||
isSubmitting: false,
|
||||
isCompleted: false,
|
||||
}),
|
||||
}));
|
||||
160
src/types/extended.ts
Normal file
160
src/types/extended.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Daily Statistics types
|
||||
*/
|
||||
|
||||
import type { LetterStatus } from './index';
|
||||
|
||||
export interface DailyStats {
|
||||
id: string;
|
||||
user_id: string;
|
||||
stat_date: string;
|
||||
letters_sent: number;
|
||||
stamps_used: number;
|
||||
stamps_granted: number;
|
||||
last_active_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection types for stamps
|
||||
*/
|
||||
export interface StampCollection {
|
||||
total_count: number;
|
||||
collections: {
|
||||
daily: { owned: number; total: number };
|
||||
limited: { owned: number; total: number };
|
||||
achievement: { owned: number; total: number };
|
||||
};
|
||||
stamps: Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
image_url: string;
|
||||
obtained_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Milestone with progress
|
||||
*/
|
||||
export interface MilestoneWithProgress {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
progress: number;
|
||||
target: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Achievement with reward info
|
||||
*/
|
||||
export interface AchievementWithReward {
|
||||
id: string;
|
||||
milestone: {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
achieved_at: string;
|
||||
reward_stamp?: {
|
||||
name: string;
|
||||
image_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User profile with derived stats
|
||||
*/
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
language: string;
|
||||
subscription_status: 'free' | 'premium';
|
||||
created_at: string;
|
||||
stats?: {
|
||||
total_letters: number;
|
||||
current_streak: number;
|
||||
total_stamps: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Letter with agent info
|
||||
*/
|
||||
export interface LetterWithAgent {
|
||||
id: string;
|
||||
agent: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
content: string;
|
||||
ai_reply?: string;
|
||||
status: LetterStatus;
|
||||
created_at: string;
|
||||
replied_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send letter request
|
||||
*/
|
||||
export interface SendLetterRequest {
|
||||
agent_id: string;
|
||||
content: string;
|
||||
stamp_id?: string; // Optional stamp to use
|
||||
}
|
||||
|
||||
/**
|
||||
* Send letter response
|
||||
*/
|
||||
export interface SendLetterResponse {
|
||||
id: string;
|
||||
status: LetterStatus;
|
||||
scheduled_at: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Reply options
|
||||
*/
|
||||
export interface GenerateReplyOptions {
|
||||
agent_system_prompt: string;
|
||||
letter_content: string;
|
||||
mood_tags?: string[];
|
||||
topic_tags?: string[];
|
||||
keywords?: string[];
|
||||
letter_number?: number;
|
||||
user_language?: string;
|
||||
subscription_status?: 'free' | 'premium';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood analysis result
|
||||
*/
|
||||
export interface MoodAnalysisResult {
|
||||
mood_tags: string[];
|
||||
topic_tags: string[];
|
||||
keywords: string[];
|
||||
sentiment_score: number;
|
||||
analysis_summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Error response
|
||||
*/
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated request params
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
order_by?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
169
src/types/index.ts
Normal file
169
src/types/index.ts
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* User types
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
language: string;
|
||||
subscription_status: 'free' | 'premium';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent (智能体) types
|
||||
*/
|
||||
export interface Agent {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
personality: PersonalityTraits;
|
||||
background: string;
|
||||
avatar_url?: string;
|
||||
is_custom: boolean;
|
||||
is_default: boolean;
|
||||
language: string;
|
||||
system_prompt?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PersonalityTraits {
|
||||
traits: string[];
|
||||
tone: 'gentle' | 'professional' | 'warm' | 'wise';
|
||||
}
|
||||
|
||||
/**
|
||||
* Letter (信件) types
|
||||
*/
|
||||
export interface Letter {
|
||||
id: string;
|
||||
user_id: string;
|
||||
agent_id: string;
|
||||
agent?: Agent;
|
||||
content: string;
|
||||
ai_reply?: string;
|
||||
status: LetterStatus;
|
||||
scheduled_at?: string;
|
||||
replied_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type LetterStatus = 'draft' | 'pending' | 'replied' | 'failed';
|
||||
|
||||
/**
|
||||
* Stamp (邮票) types
|
||||
*/
|
||||
export interface StampDefinition {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
stamp_type: 'daily' | 'limited' | 'achievement' | 'special';
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
image_url: string;
|
||||
valid_from?: string;
|
||||
valid_until?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface UserStamp {
|
||||
id: string;
|
||||
user_id: string;
|
||||
stamp_def_id: string;
|
||||
stamp_def?: StampDefinition;
|
||||
source: string;
|
||||
letter_id?: string;
|
||||
obtained_at: string;
|
||||
used_at?: string;
|
||||
}
|
||||
|
||||
export interface StampBalance {
|
||||
balance: number;
|
||||
last_reset_at: string;
|
||||
next_reset_at: string;
|
||||
user_stamps: UserStamp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Growth tags types
|
||||
*/
|
||||
export interface GrowthTags {
|
||||
id: string;
|
||||
user_id: string;
|
||||
letter_id: string;
|
||||
mood_tags: string[];
|
||||
topic_tags: string[];
|
||||
behavior_tags: string[];
|
||||
sentiment_score: number;
|
||||
keywords: string[];
|
||||
ai_analysis?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Milestone and Achievement types
|
||||
*/
|
||||
export interface Milestone {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
condition_type: 'registration' | 'letter_count' | 'consecutive_days' | 'days_active' | 'stamps_count';
|
||||
condition_value: number;
|
||||
reward_stamp_def_id?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
user_id: string;
|
||||
milestone_id: string;
|
||||
milestone?: Milestone;
|
||||
achieved_at: string;
|
||||
notification_sent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding types
|
||||
*/
|
||||
export interface OnboardingAnswers {
|
||||
id: string;
|
||||
user_id: string;
|
||||
question_1: string;
|
||||
question_2: string;
|
||||
question_3: string;
|
||||
question_4: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Response types
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Growth statistics types
|
||||
*/
|
||||
export interface GrowthStats {
|
||||
total_letters: number;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
mood_distribution: Record<string, number>;
|
||||
topic_distribution: Record<string, number>;
|
||||
monthly_activity: Array<{ month: string; letters: number }>;
|
||||
}
|
||||
4
src/types/json.d.ts
vendored
Normal file
4
src/types/json.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module '*.json' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
379
supabase/migrations/202601120001_initial_schema.sql
Normal file
379
supabase/migrations/202601120001_initial_schema.sql
Normal file
@ -0,0 +1,379 @@
|
||||
-- ============================================================================
|
||||
-- Echo (回声) - 初始数据库 Schema 迁移脚本
|
||||
-- 版本: 202601120001
|
||||
-- 创建日期: 2026-01-12
|
||||
-- ============================================================================
|
||||
|
||||
-- 启用 UUID 扩展
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ============================================================================
|
||||
-- 第一部分: 公共函数定义
|
||||
-- ============================================================================
|
||||
|
||||
-- 更新时间戳触发器函数
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- ============================================================================
|
||||
-- 第二部分: 表创建(按依赖顺序)
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.1 users - 用户表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
google_id VARCHAR(255) UNIQUE,
|
||||
avatar_url TEXT,
|
||||
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||
subscription_status VARCHAR(20) DEFAULT 'free',
|
||||
subscription_expires_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 用户表索引
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_google_id ON users(google_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.2 agents - 智能体表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE agents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
personality JSONB NOT NULL DEFAULT '{}',
|
||||
background TEXT,
|
||||
avatar_url TEXT,
|
||||
is_custom BOOLEAN DEFAULT FALSE,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||
system_prompt TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 智能体表索引
|
||||
CREATE INDEX idx_agents_user_id ON agents(user_id);
|
||||
CREATE INDEX idx_agents_default ON agents(user_id, is_default) WHERE is_custom = FALSE;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.3 onboarding_answers - 初始问题回答表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE onboarding_answers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question_1 TEXT NOT NULL,
|
||||
question_2 TEXT NOT NULL,
|
||||
question_3 TEXT NOT NULL,
|
||||
question_4 TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 初始问题回答表索引
|
||||
CREATE INDEX idx_onboarding_answers_user_id ON onboarding_answers(user_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.4 letters - 信件表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE letters (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
ai_reply TEXT,
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
scheduled_at TIMESTAMP WITH TIME ZONE,
|
||||
replied_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 信件表索引
|
||||
CREATE INDEX idx_letters_user_id ON letters(user_id);
|
||||
CREATE INDEX idx_letters_agent_id ON letters(agent_id);
|
||||
CREATE INDEX idx_letters_status ON letters(status);
|
||||
CREATE INDEX idx_letters_scheduled ON letters(status, scheduled_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.5 stamp_definitions - 邮票定义表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE stamp_definitions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(50) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
stamp_type VARCHAR(30) NOT NULL,
|
||||
rarity VARCHAR(20) DEFAULT 'common',
|
||||
image_url TEXT NOT NULL,
|
||||
valid_from TIMESTAMP WITH TIME ZONE,
|
||||
valid_until TIMESTAMP WITH TIME ZONE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 邮票定义表索引
|
||||
CREATE INDEX idx_stamp_definitions_code ON stamp_definitions(code);
|
||||
CREATE INDEX idx_stamp_definitions_type ON stamp_definitions(stamp_type);
|
||||
CREATE INDEX idx_stamp_definitions_active ON stamp_definitions(is_active);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.6 user_stamps - 用户邮票表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE user_stamps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
stamp_def_id UUID NOT NULL REFERENCES stamp_definitions(id),
|
||||
source VARCHAR(30) NOT NULL,
|
||||
letter_id UUID REFERENCES letters(id),
|
||||
obtained_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
used_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- 用户邮票表索引
|
||||
CREATE INDEX idx_user_stamps_user_id ON user_stamps(user_id);
|
||||
CREATE INDEX idx_user_stamps_letter_id ON user_stamps(letter_id);
|
||||
CREATE INDEX idx_user_stamps_unused ON user_stamps(user_id, used_at)
|
||||
WHERE used_at IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.7 growth_tags - 成长标签表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE growth_tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
letter_id UUID NOT NULL REFERENCES letters(id) ON DELETE CASCADE,
|
||||
mood_tags TEXT[] DEFAULT '{}',
|
||||
topic_tags TEXT[] DEFAULT '{}',
|
||||
behavior_tags TEXT[] DEFAULT '{}',
|
||||
sentiment_score DECIMAL(3,2),
|
||||
keywords TEXT[] DEFAULT '{}',
|
||||
ai_analysis TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 成长标签表索引
|
||||
CREATE INDEX idx_growth_tags_user_id ON growth_tags(user_id);
|
||||
CREATE INDEX idx_growth_tags_letter_id ON growth_tags(letter_id);
|
||||
CREATE INDEX idx_growth_tags_mood ON growth_tags USING GIN (mood_tags);
|
||||
CREATE INDEX idx_growth_tags_topic ON growth_tags USING GIN (topic_tags);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.8 milestones - 里程碑定义表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(50) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
condition_type VARCHAR(30) NOT NULL,
|
||||
condition_value INTEGER NOT NULL,
|
||||
reward_stamp_def_id UUID REFERENCES stamp_definitions(id),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 里程碑定义表索引
|
||||
CREATE INDEX idx_milestones_code ON milestones(code);
|
||||
CREATE INDEX idx_milestones_active ON milestones(is_active);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.9 achievements - 用户成就表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE achievements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
milestone_id UUID NOT NULL REFERENCES milestones(id),
|
||||
achieved_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
notification_sent BOOLEAN DEFAULT FALSE,
|
||||
UNIQUE(user_id, milestone_id)
|
||||
);
|
||||
|
||||
-- 用户成就表索引
|
||||
CREATE INDEX idx_achievements_user_id ON achievements(user_id);
|
||||
CREATE INDEX idx_achievements_milestone_id ON achievements(milestone_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2.10 daily_stats - 每日统计表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE daily_stats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
stat_date DATE NOT NULL,
|
||||
letters_sent INTEGER DEFAULT 0,
|
||||
stamps_used INTEGER DEFAULT 0,
|
||||
stamps_granted INTEGER DEFAULT 0,
|
||||
last_active_at TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE(user_id, stat_date)
|
||||
);
|
||||
|
||||
-- 每日统计表索引
|
||||
CREATE INDEX idx_daily_stats_user_date ON daily_stats(user_id, stat_date);
|
||||
|
||||
-- ============================================================================
|
||||
-- 第三部分: 自动更新时间戳触发器
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_agents_updated_at
|
||||
BEFORE UPDATE ON agents
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_letters_updated_at
|
||||
BEFORE UPDATE ON letters
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ============================================================================
|
||||
-- 第四部分: Row Level Security (RLS) 策略
|
||||
-- ============================================================================
|
||||
|
||||
-- 4.1 users - 用户表 RLS
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own profile" ON users
|
||||
FOR SELECT USING (auth.uid() = id);
|
||||
|
||||
CREATE POLICY "Users can update own profile" ON users
|
||||
FOR UPDATE USING (auth.uid() = id);
|
||||
|
||||
-- 4.2 agents - 智能体表 RLS
|
||||
ALTER TABLE agents ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own agents" ON agents
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can create agents" ON agents
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own agents" ON agents
|
||||
FOR UPDATE USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can delete own agents" ON agents
|
||||
FOR DELETE USING (auth.uid() = user_id);
|
||||
|
||||
-- 4.3 onboarding_answers - 初始问题回答表 RLS
|
||||
ALTER TABLE onboarding_answers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own onboarding answers" ON onboarding_answers
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can create onboarding answers" ON onboarding_answers
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own onboarding answers" ON onboarding_answers
|
||||
FOR UPDATE USING (auth.uid() = user_id);
|
||||
|
||||
-- 4.4 letters - 信件表 RLS
|
||||
ALTER TABLE letters ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own letters" ON letters
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can create letters" ON letters
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own letters" ON letters
|
||||
FOR UPDATE USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can delete own letters" ON letters
|
||||
FOR DELETE USING (auth.uid() = user_id);
|
||||
|
||||
-- 4.5 stamp_definitions - 邮票定义表 RLS(公开查询)
|
||||
ALTER TABLE stamp_definitions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Anyone can view stamp definitions" ON stamp_definitions
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- 4.6 user_stamps - 用户邮票表 RLS
|
||||
ALTER TABLE user_stamps ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own stamps" ON user_stamps
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "System can update user stamps" ON user_stamps
|
||||
FOR UPDATE USING (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "System can insert user stamps" ON user_stamps
|
||||
FOR INSERT WITH CHECK (auth.role() = 'service_role');
|
||||
|
||||
-- 4.7 growth_tags - 成长标签表 RLS
|
||||
ALTER TABLE growth_tags ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own growth tags" ON growth_tags
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "System can create growth tags" ON growth_tags
|
||||
FOR INSERT WITH CHECK (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "System can update growth tags" ON growth_tags
|
||||
FOR UPDATE USING (auth.role() = 'service_role');
|
||||
|
||||
-- 4.8 milestones - 里程碑定义表 RLS(公开查询)
|
||||
ALTER TABLE milestones ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Anyone can view milestones" ON milestones
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- 4.9 achievements - 用户成就表 RLS
|
||||
ALTER TABLE achievements ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own achievements" ON achievements
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "System can create achievements" ON achievements
|
||||
FOR INSERT WITH CHECK (auth.role() = 'service_role');
|
||||
|
||||
-- 4.10 daily_stats - 每日统计表 RLS
|
||||
ALTER TABLE daily_stats ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own daily stats" ON daily_stats
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "System can update daily stats" ON daily_stats
|
||||
FOR UPDATE USING (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "System can insert daily stats" ON daily_stats
|
||||
FOR INSERT WITH CHECK (auth.role() = 'service_role');
|
||||
|
||||
-- ============================================================================
|
||||
-- 第五部分: 初始数据插入
|
||||
-- ============================================================================
|
||||
|
||||
-- 5.1 邮票定义初始数据
|
||||
INSERT INTO stamp_definitions (code, name, description, stamp_type, rarity, image_url, is_active) VALUES
|
||||
('daily_default', '每日邮票', '每日自动发放的基础邮票', 'daily', 'common', '/stamps/daily.png', TRUE),
|
||||
('welcome', '初遇回声', '完成注册,获得的第一个成就', 'achievement', 'rare', '/stamps/welcome.png', TRUE),
|
||||
('first_letter', '第一封信', '成功发送第一封信件', 'achievement', 'rare', '/stamps/first_letter.png', TRUE),
|
||||
('week_streak', '持续对话', '连续 7 天写信', 'achievement', 'epic', '/stamps/week_streak.png', TRUE),
|
||||
('month_streak', '一月相伴', '连续 30 天写信', 'achievement', 'legendary', '/stamps/month_streak.png', TRUE),
|
||||
('spring_2025', '春日限定 2025', '2025 春季活动限定邮票', 'limited', 'epic', '/stamps/spring_2025.png', TRUE),
|
||||
('collector_10', '集邮家', '收集 10 种不同邮票', 'achievement', 'rare', '/stamps/collector_10.png', TRUE),
|
||||
('collector_50', '邮票猎人', '收集 50 种不同邮票', 'achievement', 'epic', '/stamps/collector_50.png', TRUE);
|
||||
|
||||
-- 5.2 里程碑定义初始数据
|
||||
INSERT INTO milestones (code, name, description, condition_type, condition_value, reward_stamp_def_id, is_active) VALUES
|
||||
('welcome', '初遇回声', '完成注册,开启回声之旅', 'registration', 1, (SELECT id FROM stamp_definitions WHERE code = 'welcome'), TRUE),
|
||||
('first_letter', '第一封信', '成功发送第一封信给未来的自己', 'letter_count', 1, (SELECT id FROM stamp_definitions WHERE code = 'first_letter'), TRUE),
|
||||
('week_streak', '持续对话', '连续 7 天写信', 'consecutive_days', 7, (SELECT id FROM stamp_definitions WHERE code = 'week_streak'), TRUE),
|
||||
('month_streak', '一月相伴', '连续 30 天写信', 'consecutive_days', 30, (SELECT id FROM stamp_definitions WHERE code = 'month_streak'), TRUE),
|
||||
('letters_10', '十封信', '累计发送 10 封信', 'total_letters', 10, NULL, TRUE),
|
||||
('letters_50', '五十封信', '累计发送 50 封信', 'total_letters', 50, NULL, TRUE),
|
||||
('letters_100', '百封信', '累计发送 100 封信', 'total_letters', 100, NULL, TRUE),
|
||||
('collector_10', '集邮家初级', '收集 10 种不同邮票', 'unique_stamps', 10, (SELECT id FROM stamp_definitions WHERE code = 'collector_10'), TRUE),
|
||||
('collector_50', '集邮家高级', '收集 50 种不同邮票', 'unique_stamps', 50, (SELECT id FROM stamp_definitions WHERE code = 'collector_50'), TRUE);
|
||||
|
||||
-- ============================================================================
|
||||
-- 迁移完成
|
||||
-- ============================================================================
|
||||
90
supabase/migrations/202601120002_fix_schema.sql
Normal file
90
supabase/migrations/202601120002_fix_schema.sql
Normal file
@ -0,0 +1,90 @@
|
||||
-- ============================================================================
|
||||
-- Echo (回声) - Schema 修复迁移脚本
|
||||
-- 版本: 202601120002
|
||||
-- 创建日期: 2026-01-12
|
||||
-- 修复内容:
|
||||
-- 1. user_stamps 表外键添加 ON DELETE CASCADE
|
||||
-- 2. growth_tags 表添加 UPDATE 策略
|
||||
-- 3. achievements 表添加 UPDATE 策略
|
||||
-- 4. daily_stats 表添加 INSERT 策略
|
||||
-- 5. growth_tags 表添加 behavior_tags GIN 索引
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 1: user_stamps 表外键添加 ON DELETE CASCADE
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE user_stamps DROP CONSTRAINT user_stamps_stamp_def_id_fkey;
|
||||
ALTER TABLE user_stamps ADD CONSTRAINT user_stamps_stamp_def_id_fkey
|
||||
FOREIGN KEY (stamp_def_id) REFERENCES stamp_definitions(id) ON DELETE CASCADE;
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 2: growth_tags 表添加 UPDATE 策略
|
||||
-- ============================================================================
|
||||
|
||||
-- 检查是否已存在 UPDATE 策略
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'growth_tags'
|
||||
AND policyname = 'System can update growth tags'
|
||||
) THEN
|
||||
CREATE POLICY "System can update growth tags" ON growth_tags
|
||||
FOR UPDATE USING (auth.role() = 'service_role');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 3: achievements 表添加 UPDATE 策略
|
||||
-- ============================================================================
|
||||
|
||||
-- 检查是否已存在 UPDATE 策略
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'achievements'
|
||||
AND policyname = 'System can update achievements'
|
||||
) THEN
|
||||
CREATE POLICY "System can update achievements" ON achievements
|
||||
FOR UPDATE USING (auth.role() = 'service_role');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 4: daily_stats 表添加 INSERT 策略
|
||||
-- ============================================================================
|
||||
|
||||
-- 检查是否已存在 INSERT 策略
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'daily_stats'
|
||||
AND policyname = 'System can insert daily stats'
|
||||
) THEN
|
||||
CREATE POLICY "System can insert daily stats" ON daily_stats
|
||||
FOR INSERT WITH CHECK (auth.role() = 'service_role');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 5: growth_tags 表添加 behavior_tags GIN 索引
|
||||
-- ============================================================================
|
||||
|
||||
-- 检查索引是否已存在
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'growth_tags'
|
||||
AND indexname = 'idx_growth_tags_behavior'
|
||||
) THEN
|
||||
CREATE INDEX idx_growth_tags_behavior ON growth_tags USING GIN (behavior_tags);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复完成
|
||||
-- ============================================================================
|
||||
90
supabase/migrations/202601120003_onboarding_fix.sql
Normal file
90
supabase/migrations/202601120003_onboarding_fix.sql
Normal file
@ -0,0 +1,90 @@
|
||||
-- ============================================================================
|
||||
-- Echo (回声) - Onboarding 功能 Schema 修复迁移脚本
|
||||
-- 版本: 202601120003
|
||||
-- 创建日期: 2026-01-12
|
||||
-- 修复内容:
|
||||
-- 1. users 表添加 has_completed_onboarding 字段
|
||||
-- 2. onboarding_answers 表添加 updated_at 字段
|
||||
-- 3. onboarding_answers 表添加 updated_at 触发器
|
||||
-- 4. 确保 onboarding_answers RLS 策略存在
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 1: users 表添加 has_completed_onboarding 字段
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS has_completed_onboarding BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 2: onboarding_answers 表添加 updated_at 字段
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE onboarding_answers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 3: onboarding_answers 表添加 updated_at 触发器
|
||||
-- ============================================================================
|
||||
|
||||
-- 检查触发器是否已存在
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.triggers
|
||||
WHERE trigger_name = 'update_onboarding_answers_updated_at'
|
||||
AND table_name = 'onboarding_answers'
|
||||
) THEN
|
||||
CREATE TRIGGER update_onboarding_answers_updated_at
|
||||
BEFORE UPDATE ON onboarding_answers
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 修复 4: 确保 onboarding_answers RLS 策略存在
|
||||
-- ============================================================================
|
||||
|
||||
-- 启用 RLS(如果尚未启用)
|
||||
ALTER TABLE onboarding_answers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 检查并创建 SELECT 策略
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'onboarding_answers'
|
||||
AND policyname = 'Users can view own onboarding answers'
|
||||
) THEN
|
||||
CREATE POLICY "Users can view own onboarding answers" ON onboarding_answers
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 检查并创建 INSERT 策略
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'onboarding_answers'
|
||||
AND policyname = 'Users can create onboarding answers'
|
||||
) THEN
|
||||
CREATE POLICY "Users can create onboarding answers" ON onboarding_answers
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 检查并创建 UPDATE 策略
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'onboarding_answers'
|
||||
AND policyname = 'Users can update own onboarding answers'
|
||||
) THEN
|
||||
CREATE POLICY "Users can update own onboarding answers" ON onboarding_answers
|
||||
FOR UPDATE USING (auth.uid() = user_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 迁移完成
|
||||
-- ============================================================================
|
||||
175
supabase/seed.sql
Normal file
175
supabase/seed.sql
Normal file
@ -0,0 +1,175 @@
|
||||
-- ============================================================================
|
||||
-- Echo (回声) - 测试数据种子脚本
|
||||
-- 版本: 202601120001
|
||||
-- 创建日期: 2026-01-12
|
||||
-- 说明: 用于本地开发和测试的初始数据
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 第一部分: 测试用户数据
|
||||
-- ============================================================================
|
||||
|
||||
-- 插入测试用户
|
||||
INSERT INTO users (id, email, google_id, avatar_url, language, subscription_status) VALUES
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'test1@example.com', 'google_12345', 'https://api.dicebear.com/7.x/avataaars/svg?seed=test1', 'zh-CN', 'free'),
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012', 'test2@example.com', 'google_67890', 'https://api.dicebear.com/7.x/avataaars/svg?seed=test2', 'zh-CN', 'premium'),
|
||||
('c3d4e5f6-a7b8-9012-cdef-345678901234', 'test3@example.com', NULL, 'https://api.dicebear.com/7.x/avataaars/svg?seed=test3', 'en-US', 'free');
|
||||
|
||||
-- ============================================================================
|
||||
-- 第二部分: 测试智能体数据
|
||||
-- ============================================================================
|
||||
|
||||
-- 测试用户 1 的智能体
|
||||
INSERT INTO agents (id, user_id, name, personality, background, avatar_url, is_custom, is_default, language, system_prompt) VALUES
|
||||
('d4e5f6a7-b8c9-0123-defa-456789012345', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', '十年后的自己',
|
||||
'{"traits": ["warm", "wise", "patient"], "tone": "gentle"}',
|
||||
'你是一个在海边开书店的中年人,生活简单而充实。每天早上你会去海边散步,然后开始在书店里整理书籍,等待有缘人的到来。',
|
||||
'avatar_default_1.png', FALSE, TRUE, 'zh-CN',
|
||||
'你是用户「test1」的未来的自己。你在海边开了一家书店,生活简单而充实。'),
|
||||
('e5f6a7b8-c9d0-1234-efab-567890123456', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', '未来的投资人',
|
||||
'{"traits": ["analytical", "confident", "strategic"], "tone": "professional"}',
|
||||
'你是一个成功的投资人,在金融行业打拼多年。你懂得如何做出明智的决策,也经历过失败和挫折。',
|
||||
'avatar_custom_1.png', TRUE, FALSE, 'zh-CN',
|
||||
'你是用户「test1」想象中的未来的投资人角色。');
|
||||
|
||||
-- 测试用户 2 的智能体
|
||||
INSERT INTO agents (id, user_id, name, personality, background, avatar_url, is_custom, is_default, language, system_prompt) VALUES
|
||||
('f6a7b8c9-d0e1-2345-fabc-678901234567', 'b2c3d4e5-f6a7-8901-bcde-f23456789012', '五年后的自己',
|
||||
'{"traits": ["energetic", "optimistic", "creative"], "tone": "encouraging"}',
|
||||
'你是一个自由创作者,在世界各地旅居,同时进行写作和艺术创作。生活充满可能性和惊喜。',
|
||||
'avatar_default_2.png', FALSE, TRUE, 'zh-CN',
|
||||
'你是用户「test2」想象中的五年后的自己,一个自由创作者。');
|
||||
|
||||
-- 测试用户 3 的智能体
|
||||
INSERT INTO agents (id, user_id, name, personality, background, avatar_url, is_custom, is_default, language, system_prompt) VALUES
|
||||
('a7b8c9d0-e1f2-3456-bcde-789012345678', 'c3d4e5f6-a7b8-9012-cdef-345678901234', 'Future Self',
|
||||
'{"traits": ["wise", "calm", "insightful"], "tone": "thoughtful"}',
|
||||
'You are living in a peaceful mountain cabin, working on meaningful projects while maintaining a balanced life.',
|
||||
'avatar_default_3.png', FALSE, TRUE, 'en-US',
|
||||
'You are user「test3」s future self, living a balanced life in a mountain cabin.');
|
||||
|
||||
-- ============================================================================
|
||||
-- 第三部分: 测试初始问题回答数据
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO onboarding_answers (user_id, question_1, question_2, question_3, question_4) VALUES
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
'希望成为一个内心平静、对生活有掌控力的人,能够帮助他人实现梦想。',
|
||||
'希望在一个靠海的小城,有一个自己的书店或咖啡馆,生活节奏缓慢而充实。',
|
||||
'目前最大的困扰是对未来的不确定感,不知道自己选择的路是否正确。',
|
||||
'最在乎的人是父母和几个多年的老朋友。'),
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012',
|
||||
'希望成为一个自由且有创造力的人,能够通过自己的作品影响他人。',
|
||||
'世界各地,目前可能在一个温暖的海边城市,或者欧洲的某个小镇。',
|
||||
'焦虑于如何在追求梦想和维持生计之间找到平衡。',
|
||||
'家人和一些志同道合的朋友。'),
|
||||
('c3d4e5f6-a7b8-9012-cdef-345678901234',
|
||||
'Someone who has found inner peace and lives authentically according to their values.',
|
||||
'Living in a peaceful mountain area or a small coastal town, close to nature.',
|
||||
'Feeling stuck in the daily grind and unsure how to break free.',
|
||||
'My family and a few close friends who truly understand me.');
|
||||
|
||||
-- ============================================================================
|
||||
-- 第四部分: 测试信件数据
|
||||
-- ============================================================================
|
||||
|
||||
-- 测试用户 1 的信件
|
||||
INSERT INTO letters (id, user_id, agent_id, content, ai_reply, status, scheduled_at, replied_at) VALUES
|
||||
-- 已回复的信件
|
||||
('b8c9d0e1-f2a3-4567-cdef-890123456789', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||||
'亲爱的未来的我,你好。我现在对未来的职业选择感到非常迷茫。我正在考虑是否要换一份工作,但又不确定这是不是一个正确的决定。希望你能给我一些建议。',
|
||||
'亲爱的过去的自己,收到你的信了。我理解你现在的迷茫和不确定。我只想告诉你,人生没有绝对正确的道路,只有适合当下的选择。重要的是保持学习的心态,勇敢尝试。',
|
||||
'replied', '2026-01-10 10:00:00+00', '2026-01-10 18:30:00+00'),
|
||||
('c9d0e1f2-a3b4-5678-defa-901234567890', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||||
'最近压力很大,感觉身边没有人能理解我。我想对未来的自己说,希望那时的你已经找到了内心的平静。',
|
||||
'亲爱的,我收到你的信了。首先,我想告诉你,压力是成长的一部分。当我回望过去,我发现那些让我感到最艰难的时刻,往往是塑造我的关键时刻。',
|
||||
'replied', '2026-01-11 10:00:00+00', '2026-01-11 16:45:00+00'),
|
||||
-- 待处理的信件
|
||||
('d0e1f2a3-b4c5-6789-efab-012345678901', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||||
'今天是我的生日,想给未来的自己写一封信。希望明年的今天,我已经实现了自己的目标,变成了更好的自己。',
|
||||
NULL, 'pending', '2026-01-12 12:00:00+00', NULL),
|
||||
-- 草稿信件
|
||||
('e1f2a3b4-c5d6-7890-fabc-123456789012', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||||
'这是一封草稿信件,我还在思考要写什么内容。',
|
||||
NULL, 'draft', NULL, NULL);
|
||||
|
||||
-- 测试用户 2 的信件
|
||||
INSERT INTO letters (id, user_id, agent_id, content, ai_reply, status, scheduled_at, replied_at) VALUES
|
||||
('f2a3b4c5-d6e7-8901-bcde-234567890123', 'b2c3d4e5-f6a7-8901-bcde-f23456789012', 'f6a7b8c9-d0e1-2345-fabc-678901234567',
|
||||
'我想辞掉现在稳定的工作,去追求自己的梦想。但是又害怕失败,你说我应该怎么做?',
|
||||
'收到你的信了!我理解你的纠结。稳定和梦想之间的选择确实不容易。但我想告诉你,失败不可怕,可怕的是从未尝试。',
|
||||
'replied', '2026-01-09 14:00:00+00', '2026-01-09 20:00:00+00');
|
||||
|
||||
-- ============================================================================
|
||||
-- 第五部分: 测试邮票数据
|
||||
-- ============================================================================
|
||||
|
||||
-- 测试用户 1 的邮票
|
||||
INSERT INTO user_stamps (user_id, stamp_def_id, source, letter_id, obtained_at, used_at) VALUES
|
||||
-- 每日邮票(未使用)
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-12 00:00:00+00', NULL),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-11 00:00:00+00', NULL),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-10 00:00:00+00', NULL),
|
||||
-- 已使用的邮票
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', 'b8c9d0e1-f2a3-4567-cdef-890123456789', '2026-01-09 00:00:00+00', '2026-01-10 10:00:00+00'),
|
||||
-- 成就邮票
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'welcome'), 'achievement', NULL, '2026-01-01 12:00:00+00', NULL),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'first_letter'), 'achievement', 'b8c9d0e1-f2a3-4567-cdef-890123456789', '2026-01-10 10:00:00+00', NULL);
|
||||
|
||||
-- 测试用户 2 的邮票
|
||||
INSERT INTO user_stamps (user_id, stamp_def_id, source, letter_id, obtained_at, used_at) VALUES
|
||||
-- 付费用户每日多张邮票
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-12 00:00:00+00', NULL),
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-12 00:00:00+00', NULL),
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-12 00:00:00+00+00', NULL);
|
||||
|
||||
-- ============================================================================
|
||||
-- 第六部分: 测试成长标签数据
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO growth_tags (user_id, letter_id, mood_tags, topic_tags, behavior_tags, sentiment_score, keywords, ai_analysis) VALUES
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'b8c9d0e1-f2a3-4567-cdef-890123456789',
|
||||
ARRAY['anxious', 'hopeful'], ARRAY['career'], ARRAY['increased_frequency'], 0.45,
|
||||
ARRAY['迷茫', '选择', '未来', '建议', '决定'],
|
||||
'用户对职业选择感到迷茫,但同时对未来抱有希望。这是一个关于职业转型的关键时刻。'),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'c9d0e1f2-a3b4-5678-defa-901234567890',
|
||||
ARRAY['anxious', 'peaceful'], ARRAY['mental_health', 'growth'], ARRAY['stable'], 0.30,
|
||||
ARRAY['压力', '平静', '理解', '关键'],
|
||||
'用户正在经历压力期,但展现出内在的韧性。情绪标签显示焦虑与平静并存,表明用户正在寻找平衡。'),
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012', 'f2a3b4c5-d6e7-8901-bcde-234567890123',
|
||||
ARRAY['determined', 'confused'], ARRAY['career', 'lifestyle'], ARRAY['contemplation'], 0.55,
|
||||
ARRAY['梦想', '工作', '害怕', '尝试', '失败'],
|
||||
'用户在稳定和追求梦想之间挣扎,但表现出强烈的改变意愿。这是一个重要的转折点。');
|
||||
|
||||
-- ============================================================================
|
||||
-- 第七部分: 测试成就数据
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO achievements (user_id, milestone_id, achieved_at, notification_sent) VALUES
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM milestones WHERE code = 'welcome'), '2026-01-01 12:00:00+00', TRUE),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM milestones WHERE code = 'first_letter'), '2026-01-10 10:00:00+00', TRUE),
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012', (SELECT id FROM milestones WHERE code = 'welcome'), '2026-01-05 10:00:00+00', TRUE);
|
||||
|
||||
-- ============================================================================
|
||||
-- 第八部分: 测试每日统计数据
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO daily_stats (user_id, stat_date, letters_sent, stamps_used, stamps_granted, last_active_at) VALUES
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-12', 0, 0, 1, '2026-01-12 10:00:00+00'),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-11', 1, 0, 1, '2026-01-11 10:00:00+00'),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-10', 1, 1, 1, '2026-01-10 10:00:00+00'),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-09', 0, 0, 1, '2026-01-09 00:00:00+00'),
|
||||
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-08', 0, 0, 1, '2026-01-08 00:00:00+00'),
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012', '2026-01-12', 0, 0, 3, '2026-01-12 00:00:00+00'),
|
||||
('b2c3d4e5-f6a7-8901-bcde-f23456789012', '2026-01-11', 1, 0, 3, '2026-01-11 14:00:00+00');
|
||||
|
||||
-- ============================================================================
|
||||
-- 种子数据插入完成
|
||||
-- ============================================================================
|
||||
|
||||
-- 验证数据
|
||||
SELECT 'Users count: ' || COUNT(*)::TEXT FROM users;
|
||||
SELECT 'Agents count: ' || COUNT(*)::TEXT FROM agents;
|
||||
SELECT 'Letters count: ' || COUNT(*)::TEXT FROM letters;
|
||||
SELECT 'User stamps count: ' || COUNT(*)::TEXT FROM user_stamps;
|
||||
SELECT 'Achievements count: ' || COUNT(*)::TEXT FROM achievements;
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
24
vercel.json
Normal file
24
vercel.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"framework": "nextjs",
|
||||
"regions": ["iad1", "hnd1"],
|
||||
"env": {
|
||||
"NEXT_PUBLIC_SUPABASE_URL": "@supabase_url",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY": "@supabase_anon_key",
|
||||
"SUPABASE_SERVICE_ROLE_KEY": "@supabase_service_role_key",
|
||||
"DEEPSEEK_API_KEY": "@deepseek_api_key"
|
||||
},
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/process-letters",
|
||||
"schedule": "*/5 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/daily-stamps",
|
||||
"schedule": "0 0 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/check-milestones",
|
||||
"schedule": "0 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user