From e860b9963bf7095402fa1f40b6ca50ccd82cc7c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:01:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20AI=E5=86=99=E7=BD=91=E6=96=87=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E9=A6=96=E6=9C=9F=E5=8A=9F=E8=83=BD=20=E2=80=94=20?= =?UTF-8?q?=E5=89=8D=E5=90=8E=E7=AB=AF=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 4 + backend-integration/nginx-api-proxy.conf | 33 ++++ writing-platform/README.md | 105 +++++++++++ writing-platform/backend/ecosystem.config.js | 16 ++ .../backend/src/middleware/.gitkeep | 0 .../backend/src/middleware/authMiddleware.ts | 44 +++++ .../backend/src/middleware/rateLimiter.ts | 40 ++++ writing-platform/backend/src/models/.gitkeep | 0 .../backend/src/models/userModel.ts | 40 ++++ writing-platform/backend/src/routes/.gitkeep | 0 writing-platform/backend/src/routes/ai.ts | 35 ++++ writing-platform/backend/src/routes/auth.ts | 116 ++++++++++++ writing-platform/backend/src/routes/user.ts | 25 +++ writing-platform/backend/src/server.ts | 22 ++- .../backend/src/services/.gitkeep | 0 .../backend/src/services/aiService.ts | 103 ++++++++++ .../backend/src/services/notionService.ts | 153 +++++++++++++++ .../backend/src/services/smsService.ts | 59 ++++++ .../backend/src/services/socketService.ts | 80 ++++++++ writing-platform/frontend/README.md | 73 -------- writing-platform/frontend/public/favicon.svg | 1 - writing-platform/frontend/src/App.tsx | 47 +++-- .../frontend/src/components/AICompanion.tsx | 48 +++++ .../frontend/src/components/ChatBubble.tsx | 46 +++++ .../frontend/src/components/Navbar.tsx | 63 +++++++ .../frontend/src/components/PhoneLogin.tsx | 115 ++++++++++++ .../frontend/src/components/RoleSelector.tsx | 64 +++++++ .../frontend/src/hooks/useAuth.ts | 23 +++ .../frontend/src/hooks/useSocket.ts | 65 +++++++ writing-platform/frontend/src/index.css | 31 +++ writing-platform/frontend/src/main.tsx | 5 +- .../frontend/src/pages/DashboardPage.tsx | 142 ++++++++++++++ .../frontend/src/pages/LandingPage.tsx | 150 +++++++++++++++ .../frontend/src/pages/LoginPage.tsx | 36 ++++ .../frontend/src/pages/RegisterPage.tsx | 177 ++++++++++++++++++ writing-platform/frontend/src/services/api.ts | 69 +++++++ .../frontend/src/services/socket.ts | 11 ++ .../frontend/src/stores/authStore.ts | 57 ++++++ writing-platform/nginx-writing.conf | 35 ++++ 39 files changed, 2038 insertions(+), 95 deletions(-) create mode 100644 writing-platform/README.md create mode 100644 writing-platform/backend/ecosystem.config.js delete mode 100644 writing-platform/backend/src/middleware/.gitkeep create mode 100644 writing-platform/backend/src/middleware/authMiddleware.ts create mode 100644 writing-platform/backend/src/middleware/rateLimiter.ts delete mode 100644 writing-platform/backend/src/models/.gitkeep create mode 100644 writing-platform/backend/src/models/userModel.ts delete mode 100644 writing-platform/backend/src/routes/.gitkeep create mode 100644 writing-platform/backend/src/routes/ai.ts create mode 100644 writing-platform/backend/src/routes/auth.ts create mode 100644 writing-platform/backend/src/routes/user.ts delete mode 100644 writing-platform/backend/src/services/.gitkeep create mode 100644 writing-platform/backend/src/services/aiService.ts create mode 100644 writing-platform/backend/src/services/notionService.ts create mode 100644 writing-platform/backend/src/services/smsService.ts create mode 100644 writing-platform/backend/src/services/socketService.ts delete mode 100644 writing-platform/frontend/README.md delete mode 100644 writing-platform/frontend/public/favicon.svg create mode 100644 writing-platform/frontend/src/components/AICompanion.tsx create mode 100644 writing-platform/frontend/src/components/ChatBubble.tsx create mode 100644 writing-platform/frontend/src/components/Navbar.tsx create mode 100644 writing-platform/frontend/src/components/PhoneLogin.tsx create mode 100644 writing-platform/frontend/src/components/RoleSelector.tsx create mode 100644 writing-platform/frontend/src/hooks/useAuth.ts create mode 100644 writing-platform/frontend/src/hooks/useSocket.ts create mode 100644 writing-platform/frontend/src/pages/DashboardPage.tsx create mode 100644 writing-platform/frontend/src/pages/LandingPage.tsx create mode 100644 writing-platform/frontend/src/pages/LoginPage.tsx create mode 100644 writing-platform/frontend/src/pages/RegisterPage.tsx create mode 100644 writing-platform/frontend/src/services/api.ts create mode 100644 writing-platform/frontend/src/services/socket.ts create mode 100644 writing-platform/frontend/src/stores/authStore.ts create mode 100644 writing-platform/nginx-writing.conf diff --git a/.gitignore b/.gitignore index 46a65b3d..9da4ce46 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ node_modules/ *.bak *.log backend/api-server/logs/ + +# Writing Platform build artifacts +writing-platform/frontend/dist/ +writing-platform/backend/dist/ diff --git a/backend-integration/nginx-api-proxy.conf b/backend-integration/nginx-api-proxy.conf index e24eeabe..17bea67a 100644 --- a/backend-integration/nginx-api-proxy.conf +++ b/backend-integration/nginx-api-proxy.conf @@ -63,3 +63,36 @@ location /ws/preview { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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; +} diff --git a/writing-platform/README.md b/writing-platform/README.md new file mode 100644 index 00000000..d7d18eaf --- /dev/null +++ b/writing-platform/README.md @@ -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 diff --git a/writing-platform/backend/ecosystem.config.js b/writing-platform/backend/ecosystem.config.js new file mode 100644 index 00000000..a8f2dcff --- /dev/null +++ b/writing-platform/backend/ecosystem.config.js @@ -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', + }], +}; diff --git a/writing-platform/backend/src/middleware/.gitkeep b/writing-platform/backend/src/middleware/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/writing-platform/backend/src/middleware/authMiddleware.ts b/writing-platform/backend/src/middleware/authMiddleware.ts new file mode 100644 index 00000000..befd4433 --- /dev/null +++ b/writing-platform/backend/src/middleware/authMiddleware.ts @@ -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: '认证令牌无效或已过期' }); + } +} diff --git a/writing-platform/backend/src/middleware/rateLimiter.ts b/writing-platform/backend/src/middleware/rateLimiter.ts new file mode 100644 index 00000000..d3044a4c --- /dev/null +++ b/writing-platform/backend/src/middleware/rateLimiter.ts @@ -0,0 +1,40 @@ +import { Request, Response, NextFunction } from 'express'; + +// Simple in-memory rate limiter +const requestCounts = new Map(); + +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); diff --git a/writing-platform/backend/src/models/.gitkeep b/writing-platform/backend/src/models/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/writing-platform/backend/src/models/userModel.ts b/writing-platform/backend/src/models/userModel.ts new file mode 100644 index 00000000..cc391c7b --- /dev/null +++ b/writing-platform/backend/src/models/userModel.ts @@ -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 = { + 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: '敏锐的数据分析师,擅长趋势洞察、策略建议', + }, +}; diff --git a/writing-platform/backend/src/routes/.gitkeep b/writing-platform/backend/src/routes/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/writing-platform/backend/src/routes/ai.ts b/writing-platform/backend/src/routes/ai.ts new file mode 100644 index 00000000..7cef4545 --- /dev/null +++ b/writing-platform/backend/src/routes/ai.ts @@ -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 => { + 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; diff --git a/writing-platform/backend/src/routes/auth.ts b/writing-platform/backend/src/routes/auth.ts new file mode 100644 index 00000000..b24bb4f7 --- /dev/null +++ b/writing-platform/backend/src/routes/auth.ts @@ -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 => { + 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 => { + 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 => { + 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; diff --git a/writing-platform/backend/src/routes/user.ts b/writing-platform/backend/src/routes/user.ts new file mode 100644 index 00000000..25104845 --- /dev/null +++ b/writing-platform/backend/src/routes/user.ts @@ -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; diff --git a/writing-platform/backend/src/server.ts b/writing-platform/backend/src/server.ts index 00e3f177..d0d17a8d 100644 --- a/writing-platform/backend/src/server.ts +++ b/writing-platform/backend/src/server.ts @@ -2,15 +2,24 @@ import express from 'express'; import cors from 'cors'; import http from 'http'; 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(); const app = express(); const server = http.createServer(app); -app.use(cors()); +// Middleware +app.use(cors({ + origin: process.env.FRONTEND_URL || '*', + credentials: true, +})); app.use(express.json()); +// Health check app.get('/api/writing/health', (_req, res) => { res.json({ 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; 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 }; diff --git a/writing-platform/backend/src/services/.gitkeep b/writing-platform/backend/src/services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/writing-platform/backend/src/services/aiService.ts b/writing-platform/backend/src/services/aiService.ts new file mode 100644 index 00000000..881065a7 --- /dev/null +++ b/writing-platform/backend/src/services/aiService.ts @@ -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>(); + +export async function callAI(params: { + systemPrompt: string; + userMessage: string; + userId: string; +}): Promise { + 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 = { + 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; +} diff --git a/writing-platform/backend/src/services/notionService.ts b/writing-platform/backend/src/services/notionService.ts new file mode 100644 index 00000000..bbc4ce6d --- /dev/null +++ b/writing-platform/backend/src/services/notionService.ts @@ -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(); + +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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 || '', + }; +} diff --git a/writing-platform/backend/src/services/smsService.ts b/writing-platform/backend/src/services/smsService.ts new file mode 100644 index 00000000..1593a752 --- /dev/null +++ b/writing-platform/backend/src/services/smsService.ts @@ -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(); + +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 + }); +} diff --git a/writing-platform/backend/src/services/socketService.ts b/writing-platform/backend/src/services/socketService.ts new file mode 100644 index 00000000..da617595 --- /dev/null +++ b/writing-platform/backend/src/services/socketService.ts @@ -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; +} diff --git a/writing-platform/frontend/README.md b/writing-platform/frontend/README.md deleted file mode 100644 index 7dbf7ebf..00000000 --- a/writing-platform/frontend/README.md +++ /dev/null @@ -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... - }, - }, -]) -``` diff --git a/writing-platform/frontend/public/favicon.svg b/writing-platform/frontend/public/favicon.svg deleted file mode 100644 index 6893eb13..00000000 --- a/writing-platform/frontend/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/writing-platform/frontend/src/App.tsx b/writing-platform/frontend/src/App.tsx index c4a79281..1e5b3802 100644 --- a/writing-platform/frontend/src/App.tsx +++ b/writing-platform/frontend/src/App.tsx @@ -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() { return ( -
-
-
-
- -
-
-

- Writing Platform -

-

- Ready to build something great. -

-
-
- ) + <> + + } + /> + + + + } /> + } /> + } /> + + + } + /> + + + ); } -export default App +export default App; diff --git a/writing-platform/frontend/src/components/AICompanion.tsx b/writing-platform/frontend/src/components/AICompanion.tsx new file mode 100644 index 00000000..22f30cf1 --- /dev/null +++ b/writing-platform/frontend/src/components/AICompanion.tsx @@ -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 ( +
+ {/* Avatar with breathing animation */} +
+
+ 🤖 +
+ {/* Glow ring */} +
+
+ + {showName && ( + {name} + )} +
+ ); +} diff --git a/writing-platform/frontend/src/components/ChatBubble.tsx b/writing-platform/frontend/src/components/ChatBubble.tsx new file mode 100644 index 00000000..3699f0ce --- /dev/null +++ b/writing-platform/frontend/src/components/ChatBubble.tsx @@ -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 ( +
+ {/* Avatar */} +
+ {isUser ? userNickname.charAt(0) : '🤖'} +
+ + {/* Bubble */} +
+ {!isUser && ( + + {companionName} + + )} + {content} +
+
+ ); +} diff --git a/writing-platform/frontend/src/components/Navbar.tsx b/writing-platform/frontend/src/components/Navbar.tsx new file mode 100644 index 00000000..b85930a1 --- /dev/null +++ b/writing-platform/frontend/src/components/Navbar.tsx @@ -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 ( + + ); +} diff --git a/writing-platform/frontend/src/components/PhoneLogin.tsx b/writing-platform/frontend/src/components/PhoneLogin.tsx new file mode 100644 index 00000000..0612ba51 --- /dev/null +++ b/writing-platform/frontend/src/components/PhoneLogin.tsx @@ -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 ( +
+
+ + 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" + /> +
+ +
+ +
+ 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" + /> + +
+
+ + {children} + + {error && ( +

{error}

+ )} + + +
+ ); +} diff --git a/writing-platform/frontend/src/components/RoleSelector.tsx b/writing-platform/frontend/src/components/RoleSelector.tsx new file mode 100644 index 00000000..0a97eab2 --- /dev/null +++ b/writing-platform/frontend/src/components/RoleSelector.tsx @@ -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(null); + + return ( +
+ {roles.map((role) => ( + + ))} +
+ ); +} diff --git a/writing-platform/frontend/src/hooks/useAuth.ts b/writing-platform/frontend/src/hooks/useAuth.ts new file mode 100644 index 00000000..aff24ed6 --- /dev/null +++ b/writing-platform/frontend/src/hooks/useAuth.ts @@ -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, + }; +} diff --git a/writing-platform/frontend/src/hooks/useSocket.ts b/writing-platform/frontend/src/hooks/useSocket.ts new file mode 100644 index 00000000..ae59f124 --- /dev/null +++ b/writing-platform/frontend/src/hooks/useSocket.ts @@ -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([]); + const [connected, setConnected] = useState(false); + const socketRef = useRef(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 }; +} diff --git a/writing-platform/frontend/src/index.css b/writing-platform/frontend/src/index.css index f7c0e490..7d253efb 100644 --- a/writing-platform/frontend/src/index.css +++ b/writing-platform/frontend/src/index.css @@ -9,3 +9,34 @@ body { #root { @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; +} diff --git a/writing-platform/frontend/src/main.tsx b/writing-platform/frontend/src/main.tsx index bef5202a..7cb2db95 100644 --- a/writing-platform/frontend/src/main.tsx +++ b/writing-platform/frontend/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/writing-platform/frontend/src/pages/DashboardPage.tsx b/writing-platform/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 00000000..7d426a30 --- /dev/null +++ b/writing-platform/frontend/src/pages/DashboardPage.tsx @@ -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(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 ( +
+ {/* Header */} +
+
+
+ 🌟 + 光湖码字 +
+
+ + {user.nickname}({roleLabel}) + + + +
+
+
+ + {/* Main chat area */} +
+ {/* AI Companion avatar */} +
+ + {connected ? ( + ● 已连接 + ) : ( + ○ 连接中... + )} +
+ + {/* Chat messages */} +
+ {messages.map((msg, idx) => ( + + ))} +
+
+ + {/* Input area */} +
+ setInput(e.target.value)} + placeholder={`和${companionName}说点什么...`} + className="flex-1 px-4 py-2.5 outline-none text-ink-800 text-sm bg-transparent" + /> + +
+ + {/* Quick actions */} +
+ {[ + { icon: PenLine, label: '开始写作' }, + { icon: BarChart3, label: '查看数据' }, + { icon: Megaphone, label: '看定制书需求' }, + { icon: MessageCircle, label: '讨论区' }, + ].map((action) => ( + + ))} +
+
+
+ ); +} diff --git a/writing-platform/frontend/src/pages/LandingPage.tsx b/writing-platform/frontend/src/pages/LandingPage.tsx new file mode 100644 index 00000000..85cc9c5e --- /dev/null +++ b/writing-platform/frontend/src/pages/LandingPage.tsx @@ -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 ( +
+ {/* Hero section */} +
+ {/* Background gradient */} +
+ + + + {/* Typewriter welcome */} +
+

+ "{displayed} + {!done && |}" +

+
+ + {/* Role selection buttons */} +
+ {[ + { key: 'author', label: '我是作者', emoji: '🖊️' }, + { key: 'editor', label: '我是编辑', emoji: '📊' }, + { key: 'operator', label: '我是运营', emoji: '🤝' }, + ].map((r) => ( + + ))} +
+
+ + {/* Features section */} +
+
+

+ ✨ 三大核心能力 +

+
+ {features.map((f) => ( +
+
+ +
+

{f.title}

+

{f.sub}

+

{f.desc}

+
+ ))} +
+
+
+ + {/* Stats section */} +
+
+

📈 平台数据

+
+ {[ + { label: '注册作者', value: '--' }, + { label: '今日新作', value: '--' }, + { label: 'AI使用透明度', value: '100%' }, + ].map((s) => ( +
+ {s.value} + {s.label} +
+ ))} +
+
+
+ + {/* Footer */} +
+ © 2026 光湖纪元 · 国作登字-2026-A-00037559 +
+
+ ); +} diff --git a/writing-platform/frontend/src/pages/LoginPage.tsx b/writing-platform/frontend/src/pages/LoginPage.tsx new file mode 100644 index 00000000..6b7f4574 --- /dev/null +++ b/writing-platform/frontend/src/pages/LoginPage.tsx @@ -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 ( +
+
+
+ 🌟 +

欢迎回来

+

登录光湖码字平台

+
+ +
+ +
+ +

+ 还没有账号?{' '} + + 立即注册 + +

+
+
+ ); +} diff --git a/writing-platform/frontend/src/pages/RegisterPage.tsx b/writing-platform/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 00000000..fa69b820 --- /dev/null +++ b/writing-platform/frontend/src/pages/RegisterPage.tsx @@ -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 ( +
+
+
+ 🌟 +

加入光湖码字

+

选择你的角色,开始AI创作之旅

+
+ +
+
+ {/* Role selection */} +
+ + +
+ + {/* Phone */} +
+ + 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" + /> +
+ + {/* Verification code */} +
+ +
+ 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" + /> + +
+
+ + {/* Nickname */} +
+ + 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" + /> +
+ + {/* Real name (conditional) */} +
+ + 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" + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+ +

+ 已有账号?{' '} + + 立即登录 + +

+
+
+ ); +} diff --git a/writing-platform/frontend/src/services/api.ts b/writing-platform/frontend/src/services/api.ts new file mode 100644 index 00000000..1c9a75a2 --- /dev/null +++ b/writing-platform/frontend/src/services/api.ts @@ -0,0 +1,69 @@ +const API_BASE = import.meta.env.VITE_API_BASE || '/api/writing'; + +async function request(path: string, options: RequestInit = {}): Promise { + const token = localStorage.getItem('writing_token'); + const headers: Record = { + 'Content-Type': 'application/json', + ...((options.headers as Record) || {}), + }; + 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('/auth/register', { + method: 'POST', + body: JSON.stringify(data), + }), + + login: (phone: string, code: string) => + request('/auth/login', { + method: 'POST', + body: JSON.stringify({ phone, code }), + }), + + getMe: () => + request<{ user: AuthResponse['user'] }>('/user/me'), +}; diff --git a/writing-platform/frontend/src/services/socket.ts b/writing-platform/frontend/src/services/socket.ts new file mode 100644 index 00000000..2e7b120b --- /dev/null +++ b/writing-platform/frontend/src/services/socket.ts @@ -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'], + }); +} diff --git a/writing-platform/frontend/src/stores/authStore.ts b/writing-platform/frontend/src/stores/authStore.ts new file mode 100644 index 00000000..41ce139b --- /dev/null +++ b/writing-platform/frontend/src/stores/authStore.ts @@ -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((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'); + } + } + }, +})); diff --git a/writing-platform/nginx-writing.conf b/writing-platform/nginx-writing.conf new file mode 100644 index 00000000..dde1a45c --- /dev/null +++ b/writing-platform/nginx-writing.conf @@ -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; +}