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
|
||||
*.log
|
||||
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_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 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 };
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-white">
|
||||
<div className="text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="rounded-2xl bg-brand-500 p-4 shadow-glow">
|
||||
<PenLine className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-ink-900">
|
||||
Writing Platform
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-ink-600">
|
||||
Ready to build something great.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={<DashboardPage />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<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 {
|
||||
@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 { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter basename="/writing">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</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