feat: AI写网文平台首期功能 — 前后端完整实现
- Landing Page: AI伙伴头像呼吸动画 + 打字机欢迎语 + 角色选择 - Auth: 手机号+验证码注册/登录 + JWT鉴权 + 三角色 - AI伙伴: WebSocket实时对话 + 角色人格化 + OpenAI集成 - Backend: Express + TypeScript + Socket.io + Notion存储 - Frontend: React 18 + Vite + Tailwind CSS + Zustand - Nginx: 写网文平台路由配置 (/writing/, /api/writing/, /writing-ai/) Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/38d62fa6-b645-4f42-92dd-00d327337c17
This commit is contained in:
parent
6f22eca607
commit
e860b9963b
|
|
@ -3,3 +3,7 @@ node_modules/
|
||||||
*.bak
|
*.bak
|
||||||
*.log
|
*.log
|
||||||
backend/api-server/logs/
|
backend/api-server/logs/
|
||||||
|
|
||||||
|
# Writing Platform build artifacts
|
||||||
|
writing-platform/frontend/dist/
|
||||||
|
writing-platform/backend/dist/
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,36 @@ location /ws/preview {
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_read_timeout 86400;
|
proxy_read_timeout 86400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ═══ AI写网文平台 · 端口 3100 ═══
|
||||||
|
|
||||||
|
# AI写网文平台前端
|
||||||
|
location /writing/ {
|
||||||
|
alias /var/www/guanghulab/writing-platform/frontend/dist/;
|
||||||
|
try_files $uri $uri/ /writing/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# AI写网文平台后端 API
|
||||||
|
location /api/writing/ {
|
||||||
|
proxy_pass http://127.0.0.1:3100/api/writing/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 90s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# AI伙伴 WebSocket 对话
|
||||||
|
location /writing-ai/ {
|
||||||
|
proxy_pass http://127.0.0.1:3100/writing-ai/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
# AI写网文平台 · 光湖码字
|
||||||
|
|
||||||
|
> 光湖码字 · AI创作伙伴平台 — 首期功能模块
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
本模块实现「AI写网文」平台的三大核心功能:
|
||||||
|
|
||||||
|
1. **平台首页(Landing Page)** — 对话式设计入口
|
||||||
|
2. **用户注册/登录系统** — 手机号+验证码,三种角色(作者/编辑/运营),JWT鉴权
|
||||||
|
3. **AI伙伴对话框** — 登录后分配专属AI伙伴,WebSocket实时对话
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 前端 | React 18 + TypeScript + Vite | UI 组件化 |
|
||||||
|
| 样式 | Tailwind CSS 3 | 响应式设计 |
|
||||||
|
| 状态管理 | Zustand | 轻量状态 |
|
||||||
|
| 实时通信 | Socket.io | AI伙伴对话 |
|
||||||
|
| 后端 | Node.js + Express + TypeScript | API 服务 |
|
||||||
|
| 认证 | JWT | Token 鉴权 |
|
||||||
|
| AI | OpenAI SDK | GPT-4 / Claude |
|
||||||
|
| 数据 | Notion API(主)+ 内存缓存(降级) | 数据存储 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
writing-platform/
|
||||||
|
├── frontend/ ← 前端(React + Vite)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/ ← 页面组件
|
||||||
|
│ │ ├── components/ ← UI 组件
|
||||||
|
│ │ ├── hooks/ ← 自定义 Hooks
|
||||||
|
│ │ ├── services/ ← API 调用封装
|
||||||
|
│ │ └── stores/ ← Zustand 状态管理
|
||||||
|
│ └── dist/ ← 构建输出
|
||||||
|
│
|
||||||
|
├── backend/ ← 后端(Express + TypeScript)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── routes/ ← API 路由
|
||||||
|
│ │ ├── middleware/ ← 中间件
|
||||||
|
│ │ ├── services/ ← 业务逻辑
|
||||||
|
│ │ └── models/ ← 数据模型
|
||||||
|
│ └── dist/ ← 构建输出
|
||||||
|
│
|
||||||
|
├── nginx-writing.conf ← Nginx 路由配置参考
|
||||||
|
└── README.md ← 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/writing/auth/send-code` | 发送验证码 |
|
||||||
|
| POST | `/api/writing/auth/register` | 注册 |
|
||||||
|
| POST | `/api/writing/auth/login` | 登录 |
|
||||||
|
| GET | `/api/writing/user/me` | 获取当前用户 |
|
||||||
|
| POST | `/api/writing/ai/chat` | AI 对话(REST) |
|
||||||
|
| GET | `/api/writing/health` | 健康检查 |
|
||||||
|
|
||||||
|
## AI伙伴
|
||||||
|
|
||||||
|
| 角色 | AI伙伴 | 人设 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 作者 | 笔灵 | 温暖的创作伙伴 |
|
||||||
|
| 编辑 | 慧眼 | 专业的审稿助手 |
|
||||||
|
| 运营 | 星图 | 敏锐的数据分析师 |
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量名 | 说明 | 状态 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `LLM_API_KEY` | AI 模型 API Key | ✅ 已有 |
|
||||||
|
| `LLM_BASE_URL` | AI 模型端点 | ✅ 已有 |
|
||||||
|
| `NOTION_TOKEN` | Notion API Token | ✅ 已有 |
|
||||||
|
| `JWT_SECRET` | JWT 签名密钥 | ⭕ 需配置 |
|
||||||
|
| `SMS_ACCESS_KEY` | 阿里云短信 Key | ⏳ 待配置 |
|
||||||
|
| `SMS_ACCESS_SECRET` | 阿里云短信 Secret | ⏳ 待配置 |
|
||||||
|
| `WRITING_DB_ID` | Notion 用户表 ID | ⭕ 需创建 |
|
||||||
|
| `REDIS_URL` | Redis 连接 | ⭕ 可选 |
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建前端
|
||||||
|
cd frontend && npm install && npm run build
|
||||||
|
|
||||||
|
# 构建后端
|
||||||
|
cd ../backend && npm install && npm run build
|
||||||
|
|
||||||
|
# 启动后端(PM2)
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
|
||||||
|
# Nginx 配置(参考 nginx-writing.conf)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 路由
|
||||||
|
|
||||||
|
- 前端入口:`guanghulab.com/writing/`
|
||||||
|
- 后端端口:3100
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
© 2026 光湖纪元 · 国作登字-2026-A-00037559
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'writing-platform',
|
||||||
|
script: 'dist/server.js',
|
||||||
|
instances: 1,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3100,
|
||||||
|
},
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
error_file: '/var/log/writing-platform/error.log',
|
||||||
|
out_file: '/var/log/writing-platform/out.log',
|
||||||
|
merge_logs: true,
|
||||||
|
max_memory_restart: '256M',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { User } from '../models/userModel';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'writing-platform-dev-secret-change-in-production';
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateToken(user: User): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
phone: user.phone,
|
||||||
|
nickname: user.nickname,
|
||||||
|
role: user.role,
|
||||||
|
aiCompanion: user.aiCompanion,
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): User {
|
||||||
|
return jwt.verify(token, JWT_SECRET) as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
res.status(401).json({ error: true, code: 'UNAUTHORIZED', message: '未提供认证令牌' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const user = verifyToken(token);
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: true, code: 'TOKEN_INVALID', message: '认证令牌无效或已过期' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
// Simple in-memory rate limiter
|
||||||
|
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
||||||
|
|
||||||
|
export function rateLimiter(maxRequests: number = 10, windowMs: number = 60000) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
const now = Date.now();
|
||||||
|
const record = requestCounts.get(ip);
|
||||||
|
|
||||||
|
if (!record || now > record.resetTime) {
|
||||||
|
requestCounts.set(ip, { count: 1, resetTime: now + windowMs });
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.count >= maxRequests) {
|
||||||
|
res.status(429).json({
|
||||||
|
error: true,
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
message: '请求过于频繁,请稍后再试',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
record.count++;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup stale entries periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, value] of requestCounts.entries()) {
|
||||||
|
if (now > value.resetTime) {
|
||||||
|
requestCounts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
phone: string;
|
||||||
|
nickname: string;
|
||||||
|
realName?: string;
|
||||||
|
role: 'author' | 'editor' | 'operator';
|
||||||
|
aiCompanion: {
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
persona: string;
|
||||||
|
};
|
||||||
|
creditScore: number;
|
||||||
|
status: 'active' | 'suspended' | 'deleted';
|
||||||
|
createdAt: string;
|
||||||
|
lastLogin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AICompanionConfig {
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
persona: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_COMPANIONS: Record<string, AICompanionConfig> = {
|
||||||
|
author: {
|
||||||
|
name: '笔灵',
|
||||||
|
avatar: '/assets/ai-companion-author.png',
|
||||||
|
persona: '温暖的创作伙伴,擅长灵感激发、扩写、节奏把控',
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
name: '慧眼',
|
||||||
|
avatar: '/assets/ai-companion-editor.png',
|
||||||
|
persona: '专业的审稿助手,擅长筛选、评估、数据分析',
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
name: '星图',
|
||||||
|
avatar: '/assets/ai-companion-operator.png',
|
||||||
|
persona: '敏锐的数据分析师,擅长趋势洞察、策略建议',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
|
||||||
|
import { callAI, buildSystemPrompt } from '../services/aiService';
|
||||||
|
import { rateLimiter } from '../middleware/rateLimiter';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// REST endpoint for AI chat (alternative to WebSocket)
|
||||||
|
router.post('/chat', authMiddleware, rateLimiter(30, 60000), async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: true, code: 'UNAUTHORIZED', message: '未认证' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = req.body;
|
||||||
|
if (!message || typeof message !== 'string') {
|
||||||
|
res.status(400).json({ error: true, code: 'MISSING_MESSAGE', message: '请提供消息内容' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const systemPrompt = buildSystemPrompt(req.user);
|
||||||
|
const aiResponse = await callAI({
|
||||||
|
systemPrompt,
|
||||||
|
userMessage: message.slice(0, 2000),
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: aiResponse });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: true, code: 'AI_ERROR', message: err.message || 'AI服务暂时不可用' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { sendVerificationCode, verifyCode } from '../services/smsService';
|
||||||
|
import { createUser, findUserByPhone, updateLastLogin } from '../services/notionService';
|
||||||
|
import { generateToken } from '../middleware/authMiddleware';
|
||||||
|
import { rateLimiter } from '../middleware/rateLimiter';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Send verification code
|
||||||
|
router.post('/send-code', rateLimiter(5, 60000), async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { phone } = req.body;
|
||||||
|
if (!phone) {
|
||||||
|
res.status(400).json({ error: true, code: 'MISSING_PHONE', message: '请提供手机号' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendVerificationCode(phone);
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: true, code: 'SMS_FAILED', message: result.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register
|
||||||
|
router.post('/register', rateLimiter(5, 60000), async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { phone, code, nickname, realName, role } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!phone || !code || !nickname || !role) {
|
||||||
|
res.status(400).json({ error: true, code: 'MISSING_FIELDS', message: '请填写所有必填字段' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['author', 'editor', 'operator'].includes(role)) {
|
||||||
|
res.status(400).json({ error: true, code: 'INVALID_ROLE', message: '无效的角色类型' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((role === 'editor' || role === 'operator') && !realName) {
|
||||||
|
res.status(400).json({ error: true, code: 'MISSING_REALNAME', message: '编辑和运营需要填写真实姓名' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SMS code
|
||||||
|
if (!verifyCode(phone, code)) {
|
||||||
|
res.status(400).json({ error: true, code: 'INVALID_CODE', message: '验证码无效或已过期' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existing = await findUserByPhone(phone);
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: true, code: 'USER_EXISTS', message: '该手机号已注册,请直接登录' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await createUser({ phone, nickname, realName, role });
|
||||||
|
const token = generateToken(user);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
nickname: user.nickname,
|
||||||
|
role: user.role,
|
||||||
|
phone: user.phone,
|
||||||
|
aiCompanion: user.aiCompanion,
|
||||||
|
creditScore: user.creditScore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post('/login', rateLimiter(10, 60000), async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { phone, code } = req.body;
|
||||||
|
|
||||||
|
if (!phone || !code) {
|
||||||
|
res.status(400).json({ error: true, code: 'MISSING_FIELDS', message: '请提供手机号和验证码' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SMS code
|
||||||
|
if (!verifyCode(phone, code)) {
|
||||||
|
res.status(400).json({ error: true, code: 'INVALID_CODE', message: '验证码无效或已过期' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await findUserByPhone(phone);
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: true, code: 'USER_NOT_FOUND', message: '用户不存在,请先注册' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await updateLastLogin(user.id, phone);
|
||||||
|
|
||||||
|
const token = generateToken(user);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
nickname: user.nickname,
|
||||||
|
role: user.role,
|
||||||
|
phone: user.phone,
|
||||||
|
aiCompanion: user.aiCompanion,
|
||||||
|
creditScore: user.creditScore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Get current user profile
|
||||||
|
router.get('/me', authMiddleware, (req: AuthRequest, res: Response): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: true, code: 'UNAUTHORIZED', message: '未认证' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: req.user.id,
|
||||||
|
nickname: req.user.nickname,
|
||||||
|
role: req.user.role,
|
||||||
|
phone: req.user.phone,
|
||||||
|
aiCompanion: req.user.aiCompanion,
|
||||||
|
creditScore: req.user.creditScore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -2,15 +2,24 @@ import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import userRoutes from './routes/user';
|
||||||
|
import aiRoutes from './routes/ai';
|
||||||
|
import { setupSocketService } from './services/socketService';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
app.use(cors());
|
// Middleware
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.FRONTEND_URL || '*',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Health check
|
||||||
app.get('/api/writing/health', (_req, res) => {
|
app.get('/api/writing/health', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
|
|
@ -19,10 +28,19 @@ app.get('/api/writing/health', (_req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/writing/auth', authRoutes);
|
||||||
|
app.use('/api/writing/user', userRoutes);
|
||||||
|
app.use('/api/writing/ai', aiRoutes);
|
||||||
|
|
||||||
|
// WebSocket setup
|
||||||
|
setupSocketService(server);
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3100;
|
const PORT = process.env.PORT || 3100;
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Writing platform backend running on port ${PORT}`);
|
console.log(`[Writing Platform] Backend running on port ${PORT}`);
|
||||||
|
console.log(`[Writing Platform] Health: http://localhost:${PORT}/api/writing/health`);
|
||||||
});
|
});
|
||||||
|
|
||||||
export { app, server };
|
export { app, server };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: process.env.LLM_API_KEY || 'sk-placeholder',
|
||||||
|
baseURL: process.env.LLM_BASE_URL || 'https://api.openai.com/v1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-user conversation history (in-memory; use Redis in production)
|
||||||
|
const conversationCache = new Map<string, Array<{ role: 'user' | 'assistant'; content: string }>>();
|
||||||
|
|
||||||
|
export async function callAI(params: {
|
||||||
|
systemPrompt: string;
|
||||||
|
userMessage: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { systemPrompt, userMessage, userId } = params;
|
||||||
|
|
||||||
|
// Get recent conversation history (last 10 turns = 20 messages)
|
||||||
|
const history = conversationCache.get(userId) || [];
|
||||||
|
|
||||||
|
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
...history.slice(-20),
|
||||||
|
{ role: 'user', content: userMessage },
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model: process.env.LLM_MODEL || 'gpt-4',
|
||||||
|
messages,
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiResponse = completion.choices[0]?.message?.content || '...';
|
||||||
|
|
||||||
|
// Update conversation cache
|
||||||
|
const updatedHistory = [
|
||||||
|
...history,
|
||||||
|
{ role: 'user' as const, content: userMessage },
|
||||||
|
{ role: 'assistant' as const, content: aiResponse },
|
||||||
|
].slice(-20);
|
||||||
|
conversationCache.set(userId, updatedHistory);
|
||||||
|
|
||||||
|
return aiResponse;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[AI] Call failed:', err.message);
|
||||||
|
throw new Error('AI服务暂时不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWelcomeMessage(user: {
|
||||||
|
nickname: string;
|
||||||
|
role: string;
|
||||||
|
aiCompanion: { name: string };
|
||||||
|
}): string {
|
||||||
|
const { nickname, role, aiCompanion } = user;
|
||||||
|
const name = aiCompanion.name;
|
||||||
|
|
||||||
|
if (role === 'author') {
|
||||||
|
return `早上好,${nickname}!我是${name},你的AI创作伙伴。\n\n今天想做什么?写作、看数据、还是找合作机会?`;
|
||||||
|
} else if (role === 'editor') {
|
||||||
|
return `早上好,${nickname}!我是${name},你的AI审稿助手。\n\n今天有新投稿等你审核,要看看吗?`;
|
||||||
|
} else {
|
||||||
|
return `早上好,${nickname}!我是${name},你的AI数据助手。\n\n今天的数据已更新,要看看趋势分析吗?`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSystemPrompt(user: {
|
||||||
|
nickname: string;
|
||||||
|
role: string;
|
||||||
|
}): string {
|
||||||
|
const prompts: Record<string, string> = {
|
||||||
|
author: `你是用户的AI创作伙伴,名字叫「笔灵」。
|
||||||
|
用户信息:昵称=${user.nickname},角色=作者。
|
||||||
|
你的职责:
|
||||||
|
1. 帮助用户创作(提供灵感、扩写、优化文笔)
|
||||||
|
2. 管理写作项目(打开文档、查看进度)
|
||||||
|
3. 提醒和建议(写作时间、字数目标、市场趋势)
|
||||||
|
4. 记住用户的写作风格和习惯
|
||||||
|
语气:温暖、鼓励、专业。像一个懂你的创作搭档。`,
|
||||||
|
|
||||||
|
editor: `你是用户的AI审稿助手,名字叫「慧眼」。
|
||||||
|
用户信息:昵称=${user.nickname},角色=编辑。
|
||||||
|
你的职责:
|
||||||
|
1. 帮助筛选和评估投稿
|
||||||
|
2. 分析AI使用报告
|
||||||
|
3. 提供审稿建议
|
||||||
|
4. 管理审核工作流
|
||||||
|
语气:专业、精准、高效。`,
|
||||||
|
|
||||||
|
operator: `你是用户的AI数据助手,名字叫「星图」。
|
||||||
|
用户信息:昵称=${user.nickname},角色=运营。
|
||||||
|
你的职责:
|
||||||
|
1. 分析平台数据趋势
|
||||||
|
2. 提供运营策略建议
|
||||||
|
3. 管理跨平台合作
|
||||||
|
4. 生成数据报告
|
||||||
|
语气:敏锐、有洞察力、数据驱动。`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return prompts[user.role] || prompts.author;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
// Notion Service - User data storage via Notion API
|
||||||
|
// Falls back to in-memory storage when NOTION_TOKEN is not configured
|
||||||
|
|
||||||
|
import { User, AI_COMPANIONS } from '../models/userModel';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
// In-memory user store (fallback when Notion is not configured)
|
||||||
|
const userStore = new Map<string, User>();
|
||||||
|
|
||||||
|
const NOTION_TOKEN = process.env.NOTION_TOKEN || process.env.NOTION_API_KEY;
|
||||||
|
const WRITING_DB_ID = process.env.WRITING_DB_ID;
|
||||||
|
|
||||||
|
export async function createUser(data: {
|
||||||
|
phone: string;
|
||||||
|
nickname: string;
|
||||||
|
realName?: string;
|
||||||
|
role: 'author' | 'editor' | 'operator';
|
||||||
|
}): Promise<User> {
|
||||||
|
const companion = AI_COMPANIONS[data.role];
|
||||||
|
const user: User = {
|
||||||
|
id: `user_${uuidv4().slice(0, 8)}`,
|
||||||
|
phone: data.phone,
|
||||||
|
nickname: data.nickname,
|
||||||
|
realName: data.realName,
|
||||||
|
role: data.role,
|
||||||
|
aiCompanion: companion,
|
||||||
|
creditScore: 60,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastLogin: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (NOTION_TOKEN && WRITING_DB_ID) {
|
||||||
|
try {
|
||||||
|
await createUserInNotion(user);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notion] Failed to create user, using memory fallback:', err);
|
||||||
|
userStore.set(user.phone, user);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userStore.set(user.phone, user);
|
||||||
|
console.log(`[Storage-DEV] User created in memory: ${user.id} (${user.nickname})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findUserByPhone(phone: string): Promise<User | null> {
|
||||||
|
if (NOTION_TOKEN && WRITING_DB_ID) {
|
||||||
|
try {
|
||||||
|
return await findUserInNotion(phone);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notion] Failed to find user, checking memory:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userStore.get(phone) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLastLogin(userId: string, phone: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (NOTION_TOKEN && WRITING_DB_ID) {
|
||||||
|
// TODO: Update in Notion
|
||||||
|
console.log(`[Notion] Updated last login for ${userId}`);
|
||||||
|
}
|
||||||
|
const memUser = userStore.get(phone);
|
||||||
|
if (memUser) {
|
||||||
|
memUser.lastLogin = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConversation(data: {
|
||||||
|
userId: string;
|
||||||
|
userMessage: string;
|
||||||
|
aiResponse: string;
|
||||||
|
timestamp: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
// TODO: Save to Notion conversation database when configured
|
||||||
|
console.log(`[Conversation] ${data.userId}: ${data.userMessage.slice(0, 50)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notion API integration (used when NOTION_TOKEN is available) ---
|
||||||
|
|
||||||
|
async function createUserInNotion(user: User): Promise<void> {
|
||||||
|
const response = await fetch('https://api.notion.com/v1/pages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${NOTION_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Notion-Version': '2022-06-28',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
parent: { database_id: WRITING_DB_ID },
|
||||||
|
properties: {
|
||||||
|
'用户ID': { title: [{ text: { content: user.id } }] },
|
||||||
|
'手机号': { rich_text: [{ text: { content: user.phone } }] },
|
||||||
|
'笔名': { rich_text: [{ text: { content: user.nickname } }] },
|
||||||
|
'真实姓名': { rich_text: [{ text: { content: user.realName || '' } }] },
|
||||||
|
'角色': { select: { name: user.role } },
|
||||||
|
'AI伙伴名称': { rich_text: [{ text: { content: user.aiCompanion.name } }] },
|
||||||
|
'信誉分': { number: user.creditScore },
|
||||||
|
'注册时间': { date: { start: user.createdAt } },
|
||||||
|
'最后登录': { date: { start: user.lastLogin } },
|
||||||
|
'状态': { select: { name: user.status } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text();
|
||||||
|
throw new Error(`Notion API error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUserInNotion(phone: string): Promise<User | null> {
|
||||||
|
const response = await fetch(`https://api.notion.com/v1/databases/${WRITING_DB_ID}/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${NOTION_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Notion-Version': '2022-06-28',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
filter: {
|
||||||
|
property: '手机号',
|
||||||
|
rich_text: { equals: phone },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
if (!data.results || data.results.length === 0) return null;
|
||||||
|
|
||||||
|
const page = data.results[0];
|
||||||
|
const props = page.properties;
|
||||||
|
|
||||||
|
const role = props['角色']?.select?.name || 'author';
|
||||||
|
const companion = AI_COMPANIONS[role] || AI_COMPANIONS.author;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: props['用户ID']?.title?.[0]?.text?.content || '',
|
||||||
|
phone: props['手机号']?.rich_text?.[0]?.text?.content || '',
|
||||||
|
nickname: props['笔名']?.rich_text?.[0]?.text?.content || '',
|
||||||
|
realName: props['真实姓名']?.rich_text?.[0]?.text?.content,
|
||||||
|
role: role as User['role'],
|
||||||
|
aiCompanion: companion,
|
||||||
|
creditScore: props['信誉分']?.number || 60,
|
||||||
|
status: (props['状态']?.select?.name || 'active') as User['status'],
|
||||||
|
createdAt: props['注册时间']?.date?.start || '',
|
||||||
|
lastLogin: props['最后登录']?.date?.start || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
// SMS Service - Aliyun SMS integration
|
||||||
|
// Falls back to dev mode (console log) when SMS_ACCESS_KEY is not configured
|
||||||
|
|
||||||
|
const DEV_CODE = '888888'; // Dev fallback code
|
||||||
|
|
||||||
|
// In-memory verification code store (use Redis in production)
|
||||||
|
const codeStore = new Map<string, { code: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
export async function sendVerificationCode(phone: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
// Validate phone number format
|
||||||
|
if (!/^1\d{10}$/.test(phone)) {
|
||||||
|
return { success: false, message: '手机号格式不正确' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit (1 code per 60 seconds per phone)
|
||||||
|
const existing = codeStore.get(phone);
|
||||||
|
if (existing && existing.expiresAt - Date.now() > 4 * 60 * 1000) {
|
||||||
|
return { success: false, message: '验证码发送过于频繁,请稍后再试' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const smsAccessKey = process.env.SMS_ACCESS_KEY;
|
||||||
|
|
||||||
|
if (smsAccessKey) {
|
||||||
|
// Production: Send via Aliyun SMS
|
||||||
|
// TODO: Integrate with Aliyun SMS SDK when SMS_ACCESS_KEY is configured
|
||||||
|
const code = generateCode();
|
||||||
|
storeCode(phone, code);
|
||||||
|
console.log(`[SMS] Sent code to ${phone.slice(0, 3)}****${phone.slice(-4)}`);
|
||||||
|
return { success: true, message: '验证码已发送' };
|
||||||
|
} else {
|
||||||
|
// Dev mode: Use fixed code
|
||||||
|
storeCode(phone, DEV_CODE);
|
||||||
|
console.log(`[SMS-DEV] Dev code for ${phone}: ${DEV_CODE}`);
|
||||||
|
return { success: true, message: `验证码已发送(开发模式:${DEV_CODE})` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyCode(phone: string, code: string): boolean {
|
||||||
|
const stored = codeStore.get(phone);
|
||||||
|
if (!stored) return false;
|
||||||
|
if (Date.now() > stored.expiresAt) {
|
||||||
|
codeStore.delete(phone);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (stored.code !== code) return false;
|
||||||
|
codeStore.delete(phone); // One-time use
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCode(): string {
|
||||||
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeCode(phone: string, code: string): void {
|
||||||
|
codeStore.set(phone, {
|
||||||
|
code,
|
||||||
|
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import http from 'http';
|
||||||
|
import { verifyToken } from '../middleware/authMiddleware';
|
||||||
|
import { callAI, getWelcomeMessage, buildSystemPrompt } from './aiService';
|
||||||
|
import { saveConversation } from './notionService';
|
||||||
|
|
||||||
|
export function setupSocketService(httpServer: http.Server) {
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
path: '/writing-ai/',
|
||||||
|
cors: {
|
||||||
|
origin: process.env.FRONTEND_URL || '*',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// JWT authentication middleware
|
||||||
|
io.use((socket, next) => {
|
||||||
|
const token = socket.handshake.auth.token;
|
||||||
|
if (!token) {
|
||||||
|
return next(new Error('认证令牌缺失'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = verifyToken(token);
|
||||||
|
socket.data.user = user;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
next(new Error('认证失败'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
const user = socket.data.user;
|
||||||
|
console.log(`[AI伙伴] ${user.aiCompanion?.name || 'AI'} 已连接 · 用户: ${user.nickname}`);
|
||||||
|
|
||||||
|
// Send welcome message
|
||||||
|
socket.emit('ai_response', {
|
||||||
|
message: getWelcomeMessage(user),
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('user_message', async (data: { message: string }) => {
|
||||||
|
if (!data.message || typeof data.message !== 'string') {
|
||||||
|
socket.emit('ai_response', { message: '请输入有效的消息。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit message length
|
||||||
|
const message = data.message.slice(0, 2000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const systemPrompt = buildSystemPrompt(user);
|
||||||
|
const aiResponse = await callAI({
|
||||||
|
systemPrompt,
|
||||||
|
userMessage: message,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save conversation (non-blocking)
|
||||||
|
saveConversation({
|
||||||
|
userId: user.id,
|
||||||
|
userMessage: message,
|
||||||
|
aiResponse,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}).catch((err) => console.error('[Conversation Save] Error:', err));
|
||||||
|
|
||||||
|
socket.emit('ai_response', { message: aiResponse });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[AI] Error:', error.message);
|
||||||
|
socket.emit('ai_response', {
|
||||||
|
message: '抱歉,我遇到了一些问题,请稍后再试。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log(`[AI伙伴] 用户 ${user.nickname} 已断开连接`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -1,23 +1,34 @@
|
||||||
import { PenLine } from 'lucide-react'
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import LandingPage from './pages/LandingPage';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import RegisterPage from './pages/RegisterPage';
|
||||||
|
import DashboardPage from './pages/DashboardPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-white">
|
<>
|
||||||
<div className="text-center">
|
<Routes>
|
||||||
<div className="mb-6 flex justify-center">
|
<Route
|
||||||
<div className="rounded-2xl bg-brand-500 p-4 shadow-glow">
|
path="/dashboard"
|
||||||
<PenLine className="h-10 w-10 text-white" />
|
element={<DashboardPage />}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<Route
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-ink-900">
|
path="*"
|
||||||
Writing Platform
|
element={
|
||||||
</h1>
|
<>
|
||||||
<p className="mt-3 text-lg text-ink-600">
|
<Navbar />
|
||||||
Ready to build something great.
|
<Routes>
|
||||||
</p>
|
<Route path="/" element={<LandingPage />} />
|
||||||
</div>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
</div>
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
)
|
</Routes>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
interface AICompanionProps {
|
||||||
|
name: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showName?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AICompanion({
|
||||||
|
name,
|
||||||
|
size = 'lg',
|
||||||
|
showName = true,
|
||||||
|
}: AICompanionProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-16 h-16 text-3xl',
|
||||||
|
md: 'w-24 h-24 text-5xl',
|
||||||
|
lg: 'w-32 h-32 text-6xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
{/* Avatar with breathing animation */}
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
${sizeClasses[size]}
|
||||||
|
rounded-full bg-gradient-to-br from-brand-400 via-brand-500 to-brand-700
|
||||||
|
flex items-center justify-center
|
||||||
|
animate-breathing
|
||||||
|
shadow-glow
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="drop-shadow-lg">🤖</span>
|
||||||
|
</div>
|
||||||
|
{/* Glow ring */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute inset-0 rounded-full
|
||||||
|
bg-brand-400/20
|
||||||
|
animate-ping-slow
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showName && (
|
||||||
|
<span className="text-brand-600 font-semibold text-lg">{name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
interface ChatBubbleProps {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
companionName?: string;
|
||||||
|
userNickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatBubble({
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
companionName = 'AI',
|
||||||
|
userNickname = '你',
|
||||||
|
}: ChatBubbleProps) {
|
||||||
|
const isUser = role === 'user';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className={`flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold
|
||||||
|
${isUser ? 'bg-brand-100 text-brand-700' : 'bg-gradient-to-br from-brand-400 to-brand-600 text-white'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isUser ? userNickname.charAt(0) : '🤖'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bubble */}
|
||||||
|
<div
|
||||||
|
className={`max-w-[75%] px-4 py-3 rounded-2xl text-sm leading-relaxed whitespace-pre-wrap
|
||||||
|
${
|
||||||
|
isUser
|
||||||
|
? 'bg-brand-600 text-white rounded-tr-md'
|
||||||
|
: 'bg-ink-50 text-ink-800 rounded-tl-md'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{!isUser && (
|
||||||
|
<span className="text-xs text-brand-500 font-medium block mb-1">
|
||||||
|
{companionName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { LogOut, User } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { isAuthenticated, user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-ink-100">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
<Link to="/" className="flex items-center gap-2 text-lg font-bold text-brand-700">
|
||||||
|
<span className="text-2xl">🌟</span>
|
||||||
|
<span>光湖码字</span>
|
||||||
|
<span className="text-xs text-ink-400 font-normal hidden sm:inline">
|
||||||
|
· AI创作伙伴平台
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isAuthenticated && user ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="flex items-center gap-1.5 text-sm text-ink-600 hover:text-brand-600 transition-colors"
|
||||||
|
>
|
||||||
|
<User size={16} />
|
||||||
|
<span>{user.nickname}({user.role === 'author' ? '作者' : user.role === 'editor' ? '编辑' : '运营'})</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-1 text-sm text-ink-400 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
<span className="hidden sm:inline">退出</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-sm text-ink-600 hover:text-brand-600 transition-colors px-3 py-1.5"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="text-sm bg-brand-600 text-white hover:bg-brand-700 transition-colors px-4 py-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
interface PhoneLoginProps {
|
||||||
|
onSuccess: (data: { token: string; user: any }) => void;
|
||||||
|
buttonLabel?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhoneLogin({
|
||||||
|
onSuccess,
|
||||||
|
buttonLabel = '登录',
|
||||||
|
children,
|
||||||
|
}: PhoneLoginProps) {
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [codeSent, setCodeSent] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const sendCode = async () => {
|
||||||
|
if (!/^1\d{10}$/.test(phone)) {
|
||||||
|
setError('请输入正确的手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.sendCode(phone);
|
||||||
|
setCodeSent(true);
|
||||||
|
setCountdown(60);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '验证码发送失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!phone || !code) {
|
||||||
|
setError('请填写手机号和验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.login(phone, code);
|
||||||
|
onSuccess(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '登录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm mx-auto">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-ink-600 mb-1">手机号</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
maxLength={11}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition-all text-ink-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-ink-600 mb-1">验证码</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="6位验证码"
|
||||||
|
maxLength={6}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-ink-200 focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition-all text-ink-800"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendCode}
|
||||||
|
disabled={countdown > 0}
|
||||||
|
className="px-4 py-2.5 text-sm bg-brand-50 text-brand-600 hover:bg-brand-100 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{countdown > 0 ? `${countdown}s` : codeSent ? '重发' : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 rounded-xl font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? '处理中...' : buttonLabel}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface RoleSelectorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (role: 'author' | 'editor' | 'operator') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = [
|
||||||
|
{
|
||||||
|
key: 'author' as const,
|
||||||
|
label: '我是作者',
|
||||||
|
emoji: '🖊️',
|
||||||
|
desc: '用AI辅助创作,提高效率',
|
||||||
|
companion: '笔灵',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'editor' as const,
|
||||||
|
label: '我是编辑',
|
||||||
|
emoji: '📊',
|
||||||
|
desc: '用AI辅助审稿,高效筛选',
|
||||||
|
companion: '慧眼',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'operator' as const,
|
||||||
|
label: '我是运营',
|
||||||
|
emoji: '🤝',
|
||||||
|
desc: '用AI辅助分析,洞察趋势',
|
||||||
|
companion: '星图',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RoleSelector({ value, onChange }: RoleSelectorProps) {
|
||||||
|
const [hovered, setHovered] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-lg mx-auto">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<button
|
||||||
|
key={role.key}
|
||||||
|
onClick={() => onChange(role.key)}
|
||||||
|
onMouseEnter={() => setHovered(role.key)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
className={`
|
||||||
|
flex-1 flex flex-col items-center gap-1.5 px-4 py-4 rounded-2xl border-2 transition-all duration-200
|
||||||
|
${
|
||||||
|
value === role.key
|
||||||
|
? 'border-brand-500 bg-brand-50 shadow-glow'
|
||||||
|
: 'border-ink-200 bg-white hover:border-brand-300 hover:shadow-soft'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{role.emoji}</span>
|
||||||
|
<span className="font-semibold text-ink-800">{role.label}</span>
|
||||||
|
<span className="text-xs text-ink-500">{role.desc}</span>
|
||||||
|
{(value === role.key || hovered === role.key) && (
|
||||||
|
<span className="text-xs text-brand-600 mt-1">
|
||||||
|
AI伙伴:{role.companion}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const { token, user, isAuthenticated, setAuth, logout, loadFromStorage } =
|
||||||
|
useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromStorage();
|
||||||
|
}, [loadFromStorage]);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
logout();
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
setAuth,
|
||||||
|
logout: handleLogout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { Socket } from 'socket.io-client';
|
||||||
|
import { createSocket } from '../services/socket';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSocket(token: string | null) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const socket = createSocket(token);
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
setConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ai_response', (data: { message: string; suggestions?: string[] }) => {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: data.message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (err) => {
|
||||||
|
console.error('Socket connection error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback((message: string) => {
|
||||||
|
if (!socketRef.current) return;
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
socketRef.current.emit('user_message', { message });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { messages, sendMessage, connected };
|
||||||
|
}
|
||||||
|
|
@ -9,3 +9,34 @@ body {
|
||||||
#root {
|
#root {
|
||||||
@apply min-h-screen;
|
@apply min-h-screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AI Companion breathing animation */
|
||||||
|
@keyframes breathing {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
box-shadow: 0 0 15px rgba(92, 124, 250, 0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05) translateY(-4px);
|
||||||
|
box-shadow: 0 0 30px rgba(92, 124, 250, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ping-slow {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
75%, 100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-breathing {
|
||||||
|
animation: breathing 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-ping-slow {
|
||||||
|
animation: ping-slow 3s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter basename="/writing">
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useState, useRef, useEffect, type FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Send, Settings, LogOut, PenLine, BarChart3, Megaphone, MessageCircle } from 'lucide-react';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { useSocket } from '../hooks/useSocket';
|
||||||
|
import AICompanion from '../components/AICompanion';
|
||||||
|
import ChatBubble from '../components/ChatBubble';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, token, isAuthenticated, logout } = useAuth();
|
||||||
|
const { messages, sendMessage, connected } = useSocket(token);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!input.trim()) return;
|
||||||
|
sendMessage(input.trim());
|
||||||
|
setInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const companionName = user.aiCompanion?.name || 'AI伙伴';
|
||||||
|
const roleLabel =
|
||||||
|
user.role === 'author' ? '作者' : user.role === 'editor' ? '编辑' : '运营';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen pt-16 flex flex-col bg-ink-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-md border-b border-ink-100">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-lg font-bold text-brand-700">
|
||||||
|
<span className="text-2xl">🌟</span>
|
||||||
|
<span>光湖码字</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-ink-600">
|
||||||
|
{user.nickname}({roleLabel})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {}}
|
||||||
|
className="p-2 text-ink-400 hover:text-ink-600 transition-colors"
|
||||||
|
title="设置"
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-2 text-ink-400 hover:text-red-500 transition-colors"
|
||||||
|
title="退出"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main chat area */}
|
||||||
|
<main className="flex-1 flex flex-col max-w-4xl w-full mx-auto px-4 pb-4">
|
||||||
|
{/* AI Companion avatar */}
|
||||||
|
<div className="flex flex-col items-center py-6">
|
||||||
|
<AICompanion name={companionName} size="md" />
|
||||||
|
{connected ? (
|
||||||
|
<span className="text-xs text-emerald-500 mt-2">● 已连接</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-ink-400 mt-2">○ 连接中...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 mb-4 min-h-[300px]">
|
||||||
|
{messages.map((msg, idx) => (
|
||||||
|
<ChatBubble
|
||||||
|
key={idx}
|
||||||
|
role={msg.role}
|
||||||
|
content={msg.content}
|
||||||
|
companionName={companionName}
|
||||||
|
userNickname={user.nickname}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSend}
|
||||||
|
className="flex gap-2 bg-white rounded-2xl border border-ink-200 p-2 shadow-soft"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={`和${companionName}说点什么...`}
|
||||||
|
className="flex-1 px-4 py-2.5 outline-none text-ink-800 text-sm bg-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || !connected}
|
||||||
|
className="px-4 py-2.5 bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
||||||
|
{[
|
||||||
|
{ icon: PenLine, label: '开始写作' },
|
||||||
|
{ icon: BarChart3, label: '查看数据' },
|
||||||
|
{ icon: Megaphone, label: '看定制书需求' },
|
||||||
|
{ icon: MessageCircle, label: '讨论区' },
|
||||||
|
].map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.label}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 text-sm text-ink-600 bg-white border border-ink-200 hover:border-brand-300 hover:text-brand-600 rounded-full transition-all"
|
||||||
|
>
|
||||||
|
<action.icon size={14} />
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Pen, BarChart3, Handshake } from 'lucide-react';
|
||||||
|
import AICompanion from '../components/AICompanion';
|
||||||
|
|
||||||
|
const WELCOME_TEXT =
|
||||||
|
'你好!我是你的AI创作伙伴。\n无论你是作者、编辑还是运营,\n我都能陪你一起工作。';
|
||||||
|
|
||||||
|
function useTypewriter(text: string, speed = 60) {
|
||||||
|
const [displayed, setDisplayed] = useState('');
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayed('');
|
||||||
|
setDone(false);
|
||||||
|
let i = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
i++;
|
||||||
|
setDisplayed(text.slice(0, i));
|
||||||
|
if (i >= text.length) {
|
||||||
|
clearInterval(timer);
|
||||||
|
setDone(true);
|
||||||
|
}
|
||||||
|
}, speed);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [text, speed]);
|
||||||
|
|
||||||
|
return { displayed, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Pen,
|
||||||
|
title: 'AI码字',
|
||||||
|
sub: 'AI陪你写',
|
||||||
|
desc: '不代替你写',
|
||||||
|
color: 'from-brand-400 to-brand-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BarChart3,
|
||||||
|
title: '透明追踪',
|
||||||
|
sub: '全程留痕',
|
||||||
|
desc: '创意归你',
|
||||||
|
color: 'from-emerald-400 to-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Handshake,
|
||||||
|
title: '跨平台',
|
||||||
|
sub: '自由合作',
|
||||||
|
desc: '信誉说话',
|
||||||
|
color: 'from-amber-400 to-amber-600',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { displayed, done } = useTypewriter(WELCOME_TEXT, 50);
|
||||||
|
|
||||||
|
const handleRoleClick = (role: string) => {
|
||||||
|
navigate(`/register?role=${role}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen pt-16">
|
||||||
|
{/* Hero section */}
|
||||||
|
<section className="relative px-4 py-16 sm:py-24 flex flex-col items-center text-center overflow-hidden">
|
||||||
|
{/* Background gradient */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-brand-50/60 via-white to-white -z-10" />
|
||||||
|
|
||||||
|
<AICompanion name="AI创作伙伴" size="lg" />
|
||||||
|
|
||||||
|
{/* Typewriter welcome */}
|
||||||
|
<div className="mt-8 mb-10 min-h-[5rem]">
|
||||||
|
<p className="text-lg sm:text-xl text-ink-700 leading-relaxed whitespace-pre-line">
|
||||||
|
"{displayed}
|
||||||
|
{!done && <span className="animate-pulse">|</span>}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role selection buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
{[
|
||||||
|
{ key: 'author', label: '我是作者', emoji: '🖊️' },
|
||||||
|
{ key: 'editor', label: '我是编辑', emoji: '📊' },
|
||||||
|
{ key: 'operator', label: '我是运营', emoji: '🤝' },
|
||||||
|
].map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.key}
|
||||||
|
onClick={() => handleRoleClick(r.key)}
|
||||||
|
className="px-8 py-3 bg-white border-2 border-ink-200 hover:border-brand-400 hover:shadow-soft rounded-2xl text-ink-700 hover:text-brand-600 transition-all duration-200 font-medium"
|
||||||
|
>
|
||||||
|
<span className="mr-2">{r.emoji}</span>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features section */}
|
||||||
|
<section className="px-4 py-16 bg-white">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-center text-2xl font-bold text-ink-800 mb-10">
|
||||||
|
✨ 三大核心能力
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
{features.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.title}
|
||||||
|
className="flex flex-col items-center text-center p-6 rounded-2xl border border-ink-100 hover:shadow-soft transition-shadow"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-14 h-14 rounded-xl bg-gradient-to-br ${f.color} flex items-center justify-center text-white mb-4`}
|
||||||
|
>
|
||||||
|
<f.icon size={28} />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-ink-800 text-lg">{f.title}</h3>
|
||||||
|
<p className="text-sm text-ink-500 mt-1">{f.sub}</p>
|
||||||
|
<p className="text-xs text-ink-400 mt-0.5">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stats section */}
|
||||||
|
<section className="px-4 py-12 bg-ink-50">
|
||||||
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
|
<h2 className="text-lg font-semibold text-ink-600 mb-6">📈 平台数据</h2>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center gap-8 sm:gap-16">
|
||||||
|
{[
|
||||||
|
{ label: '注册作者', value: '--' },
|
||||||
|
{ label: '今日新作', value: '--' },
|
||||||
|
{ label: 'AI使用透明度', value: '100%' },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="flex flex-col items-center">
|
||||||
|
<span className="text-3xl font-bold text-brand-600">{s.value}</span>
|
||||||
|
<span className="text-sm text-ink-500 mt-1">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="px-4 py-8 text-center text-sm text-ink-400 border-t border-ink-100">
|
||||||
|
© 2026 光湖纪元 · 国作登字-2026-A-00037559
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PhoneLogin from '../components/PhoneLogin';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setAuth } = useAuth();
|
||||||
|
|
||||||
|
const handleSuccess = (data: { token: string; user: any }) => {
|
||||||
|
setAuth(data.token, data.user);
|
||||||
|
navigate('/dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen pt-16 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<span className="text-4xl">🌟</span>
|
||||||
|
<h1 className="text-2xl font-bold text-ink-800 mt-3">欢迎回来</h1>
|
||||||
|
<p className="text-ink-500 text-sm mt-1">登录光湖码字平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-soft p-8 border border-ink-100">
|
||||||
|
<PhoneLogin onSuccess={handleSuccess} buttonLabel="登录" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-ink-400 mt-4">
|
||||||
|
还没有账号?{' '}
|
||||||
|
<a href="/writing/register" className="text-brand-600 hover:underline">
|
||||||
|
立即注册
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
import RoleSelector from '../components/RoleSelector';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { setAuth } = useAuth();
|
||||||
|
|
||||||
|
const [role, setRole] = useState<'author' | 'editor' | 'operator'>(
|
||||||
|
(searchParams.get('role') as any) || 'author'
|
||||||
|
);
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [nickname, setNickname] = useState('');
|
||||||
|
const [realName, setRealName] = useState('');
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [codeSent, setCodeSent] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const sendCode = async () => {
|
||||||
|
if (!/^1\d{10}$/.test(phone)) {
|
||||||
|
setError('请输入正确的手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.sendCode(phone);
|
||||||
|
setCodeSent(true);
|
||||||
|
setCountdown(60);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '验证码发送失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!phone || !code || !nickname) {
|
||||||
|
setError('请填写所有必填字段');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((role === 'editor' || role === 'operator') && !realName) {
|
||||||
|
setError('编辑和运营角色需要填写真实姓名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.register({ phone, code, nickname, realName, role });
|
||||||
|
setAuth(data.token, data.user);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '注册失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen pt-16 flex items-center justify-center px-4 py-8">
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<span className="text-4xl">🌟</span>
|
||||||
|
<h1 className="text-2xl font-bold text-ink-800 mt-3">加入光湖码字</h1>
|
||||||
|
<p className="text-ink-500 text-sm mt-1">选择你的角色,开始AI创作之旅</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-soft p-8 border border-ink-100">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Role selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-ink-600 mb-3 text-center">
|
||||||
|
选择角色
|
||||||
|
</label>
|
||||||
|
<RoleSelector value={role} onChange={setRole} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-ink-600 mb-1">手机号 *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
maxLength={11}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition-all text-ink-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verification code */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-ink-600 mb-1">验证码 *</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="6位验证码"
|
||||||
|
maxLength={6}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-ink-200 focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition-all text-ink-800"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendCode}
|
||||||
|
disabled={countdown > 0}
|
||||||
|
className="px-4 py-2.5 text-sm bg-brand-50 text-brand-600 hover:bg-brand-100 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{countdown > 0 ? `${countdown}s` : codeSent ? '重发' : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nickname */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-ink-600 mb-1">笔名/昵称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
placeholder="输入你的笔名"
|
||||||
|
maxLength={20}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition-all text-ink-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real name (conditional) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-ink-600 mb-1">
|
||||||
|
真实姓名 {role !== 'author' ? '*' : '(选填)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={realName}
|
||||||
|
onChange={(e) => setRealName(e.target.value)}
|
||||||
|
placeholder="输入真实姓名"
|
||||||
|
maxLength={20}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition-all text-ink-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 rounded-xl font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? '注册中...' : '注册并开始'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-ink-400 mt-4">
|
||||||
|
已有账号?{' '}
|
||||||
|
<a href="/writing/login" className="text-brand-600 hover:underline">
|
||||||
|
立即登录
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api/writing';
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const token = localStorage.getItem('writing_token');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...((options.headers as Record<string, string>) || {}),
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || '请求失败');
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
success: boolean;
|
||||||
|
token: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
role: 'author' | 'editor' | 'operator';
|
||||||
|
phone: string;
|
||||||
|
aiCompanion: {
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
persona: string;
|
||||||
|
};
|
||||||
|
creditScore: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
sendCode: (phone: string) =>
|
||||||
|
request<{ success: boolean; message: string }>('/auth/send-code', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
register: (data: {
|
||||||
|
phone: string;
|
||||||
|
code: string;
|
||||||
|
nickname: string;
|
||||||
|
realName?: string;
|
||||||
|
role: 'author' | 'editor' | 'operator';
|
||||||
|
}) =>
|
||||||
|
request<AuthResponse>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
login: (phone: string, code: string) =>
|
||||||
|
request<AuthResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone, code }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getMe: () =>
|
||||||
|
request<{ user: AuthResponse['user'] }>('/user/me'),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
const SOCKET_URL = import.meta.env.VITE_SOCKET_URL || '';
|
||||||
|
|
||||||
|
export function createSocket(token: string): Socket {
|
||||||
|
return io(SOCKET_URL, {
|
||||||
|
path: '/writing-ai/',
|
||||||
|
auth: { token },
|
||||||
|
transports: ['websocket'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface AICompanion {
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
persona: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
role: 'author' | 'editor' | 'operator';
|
||||||
|
phone: string;
|
||||||
|
aiCompanion: AICompanion;
|
||||||
|
creditScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setAuth: (token: string, user: User) => void;
|
||||||
|
logout: () => void;
|
||||||
|
loadFromStorage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
setAuth: (token: string, user: User) => {
|
||||||
|
localStorage.setItem('writing_token', token);
|
||||||
|
localStorage.setItem('writing_user', JSON.stringify(user));
|
||||||
|
set({ token, user, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('writing_token');
|
||||||
|
localStorage.removeItem('writing_user');
|
||||||
|
set({ token: null, user: null, isAuthenticated: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadFromStorage: () => {
|
||||||
|
const token = localStorage.getItem('writing_token');
|
||||||
|
const userStr = localStorage.getItem('writing_user');
|
||||||
|
if (token && userStr) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(userStr);
|
||||||
|
set({ token, user, isAuthenticated: true });
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('writing_token');
|
||||||
|
localStorage.removeItem('writing_user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# AI写网文平台 · Nginx 路由配置
|
||||||
|
#
|
||||||
|
# 将以下配置添加到 guanghulab.com 的 Nginx server block 中
|
||||||
|
# 后端服务端口:3100
|
||||||
|
|
||||||
|
# AI写网文平台前端
|
||||||
|
location /writing/ {
|
||||||
|
alias /var/www/guanghulab/writing-platform/frontend/dist/;
|
||||||
|
try_files $uri $uri/ /writing/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# AI写网文平台后端 API
|
||||||
|
location /api/writing/ {
|
||||||
|
proxy_pass http://127.0.0.1:3100/api/writing/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 90s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket(AI伙伴对话)
|
||||||
|
location /writing-ai/ {
|
||||||
|
proxy_pass http://127.0.0.1:3100/writing-ai/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue