Merge pull request #18 from maojindao55/develop

Develop
This commit is contained in:
maojindao55
2025-04-01 22:07:03 +08:00
committed by GitHub
37 changed files with 1798 additions and 152 deletions

2
.gitignore vendored
View File

@@ -7,7 +7,7 @@ yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
package-lock.json
.wrangler/
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View File

@@ -1 +1,2 @@
# 启动开发服务器
wrangler pages dev -- npm run dev

13
friends.sql Normal file
View File

@@ -0,0 +1,13 @@
PRAGMA defer_foreign_keys=TRUE;
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone VARCHAR(11) NOT NULL UNIQUE,
nickname VARCHAR(50),
avatar_url TEXT,
status INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users VALUES(1,'13800138000','测试用户',NULL,1,'2025-03-26 08:39:15','2025-03-26 08:39:15','2025-03-26 08:39:15');
INSERT INTO users VALUES(2,'13391881866','Hobby',NULL,1,'2025-03-26 09:42:30','2025-03-27 05:50:04','2025-03-27 03:21:40');

View File

@@ -0,0 +1,80 @@
async function verifyToken(token, env) {
try {
const [headerB64, payloadB64, signature] = token.split('.');
// 验证签名
const input = `${headerB64}.${payloadB64}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(env.JWT_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signatureBytes = Uint8Array.from(atob(
signature.replace(/-/g, '+').replace(/_/g, '/')
), c => c.charCodeAt(0));
const isValid = await crypto.subtle.verify(
'HMAC',
key,
signatureBytes,
encoder.encode(input)
);
if (!isValid) {
throw new Error('Invalid signature');
}
// 解码 payload
const payload = JSON.parse(atob(
payloadB64.replace(/-/g, '+').replace(/_/g, '/')
));
// 检查过期时间
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token expired');
}
return payload;
} catch (error) {
throw new Error('Invalid token');
}
}
// 中间件函数
export async function onRequest(context) {
try {
const authAccess = context.env.AUTH_ACCESS;
console.log('authAccess', authAccess);
if (!authAccess || authAccess === '0' || context.request.url.includes('/login') || context.request.url.includes('/sendcode') || context.request.url.includes('/test-db')) {
console.log('跳过权限校验');
context.data = { user: null };
return context.next();
}
const authHeader = context.request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('No token provided');
}
const token = authHeader.split(' ')[1];
const payload = await verifyToken(token, context.env);
// 直接在原有的 request 对象上添加 context
context.data = { user: payload };
console.log('context.request.user', context.data);
return context.next();
} catch (error) {
console.error(error.message, context.request.url);
return new Response(JSON.stringify({ error: error.message }), {
status: 401,
headers: {
'Content-Type': 'application/json'
}
});
}
}

View File

@@ -1,13 +1,13 @@
import {generateAICharacters } from '../../src/config/aiCharacters';
import { groups } from '../../src/config/groups';
export async function onRequestGet({ env, request }) {
console.log('init');
export async function onRequestGet(context) {
try {
return Response.json({
code: 200,
data: {
groups: groups,
characters: generateAICharacters('#groupName#', '#allTags#'),
user: context.data.user || null
}
});
} catch (error) {

196
functions/api/login.ts Normal file
View File

@@ -0,0 +1,196 @@
interface Env {
bgkv: KVNamespace;
JWT_SECRET: string;
bgdb: D1Database;
}
export const onRequestPost: PagesFunction<Env> = async (context) => {
try {
const { request, env } = context;
// 获取请求体
const body = await request.json();
const { phone, code } = body;
// 验证手机号格式
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
return new Response(
JSON.stringify({
success: false,
message: '无效的手机号码'
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// 验证码格式检查
if (!code || !/^\d{6}$/.test(code)) {
return new Response(
JSON.stringify({
success: false,
message: '验证码格式错误'
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// 从 KV 中获取存储的验证码
const storedCode = await env.bgkv.get(`sms:${phone}`);
if (!storedCode || storedCode !== code) {
return new Response(
JSON.stringify({
success: false,
message: '验证码错误或已过期'
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// 验证成功后,处理用户数据
const db = env.bgdb; // 假设你的 D1 数据库实例名为 DB
// 查询用户是否存在
const existingUser = await db.prepare(
"SELECT id, phone, nickname FROM users WHERE phone = ?"
).bind(phone).first();
console.log('existingUser', existingUser);
let userId;
if (!existingUser) {
// 用户不存在,创建新用户
const result = await db.prepare(`
INSERT INTO users (phone, nickname, status, created_at, updated_at, last_login_at)
VALUES (?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`).bind(phone, `用户${phone.substring(7)}`).run();
userId = result.lastRowId;
} else {
// 用户存在,更新登录时间
await db.prepare(`
UPDATE users
SET last_login_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE phone = ?
`).bind(phone).run();
userId = existingUser.id;
}
// 获取完整的用户信息
const userInfo = await db.prepare(`
SELECT id, phone, nickname, avatar_url, status
FROM users
WHERE phone = ?
`).bind(phone).first();
userId = userInfo.id
// 生成 token
const token = await generateToken(userId, env);
// 删除验证码
await env.bgkv.delete(`sms:${phone}`);
// 返回用户信息和token
return new Response(
JSON.stringify({
success: true,
message: '登录成功',
data: {
token,
user: userInfo
}
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
);
} catch (error) {
console.error(error);
return new Response(
JSON.stringify({
success: false,
message: '服务器错误'
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
};
// 修改为 async 函数
async function generateToken(userId: string, env: Env): Promise<string> {
const header = {
alg: 'HS256',
typ: 'JWT'
};
const payload = {
userId,
exp: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60), // 7天过期
iat: Math.floor(Date.now() / 1000)
};
// Base64Url 编码
const encodeBase64Url = (data: object) => {
return btoa(JSON.stringify(data))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};
// 生成签名
const generateSignature = async (input: string, secret: string) => {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(input)
);
return btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};
const headerEncoded = encodeBase64Url(header);
const payloadEncoded = encodeBase64Url(payload);
const signature = await generateSignature(
`${headerEncoded}.${payloadEncoded}`,
env.JWT_SECRET
);
return `${headerEncoded}.${payloadEncoded}.${signature}`;
}

85
functions/api/sendCode.ts Normal file
View File

@@ -0,0 +1,85 @@
import { sendSMS } from '../utils/sms';
interface Env {
ALIYUN_ACCESS_KEY_ID: string;
ALIYUN_ACCESS_KEY_SECRET: string;
ALIYUN_SMS_SIGN_NAME: string;
ALIYUN_SMS_TEMPLATE_CODE: string;
bgkv: KVNamespace;
}
export const onRequestPost: PagesFunction<Env> = async (context) => {
const { request, env } = context;
try {
const { phone } = await request.json();
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
return new Response(
JSON.stringify({
success: false,
message: '请输入正确的手机号'
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 开发环境使用固定验证码
const verificationCode = env.CF_PAGES_ENVIRONMENT !== 'production'
? '123456'
: Math.random().toString().slice(-6);
if (env.CF_PAGES_ENVIRONMENT === 'production') {
try {
await sendSMS(phone, verificationCode, {
accessKeyId: env.ALIYUN_ACCESS_KEY_ID,
accessKeySecret: env.ALIYUN_ACCESS_KEY_SECRET,
signName: env.ALIYUN_SMS_SIGN_NAME,
templateCode: env.ALIYUN_SMS_TEMPLATE_CODE
});
} catch (error) {
console.error('SMS Error:', error);
return new Response(
JSON.stringify({
success: false,
message: error instanceof Error ? error.message : '发送验证码失败,请重试'
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
// 存储验证码到 KV设置 5 分钟过期
await env.bgkv.put(`sms:${phone}`, verificationCode, {
expirationTtl: 5 * 60 // 5分钟
});
return new Response(
JSON.stringify({
success: true,
message: '验证码发送成功'
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Request Error:', error);
return new Response(
JSON.stringify({
success: false,
message: '请求格式错误'
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
};

32
functions/api/test-db.ts Normal file
View File

@@ -0,0 +1,32 @@
interface Env {
bgdb: D1Database;
}
export const onRequestGet: PagesFunction<Env> = async (context) => {
try {
const { env } = context;
const db = env.bgdb;
// 测试数据库连接
const result = await db.prepare(
"SELECT * FROM users"
).all();
return new Response(JSON.stringify({
success: true,
tables: result,
env: Object.keys(env)
}), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
return new Response(JSON.stringify({
success: false,
error: error.message,
stack: error.stack
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@@ -0,0 +1,64 @@
interface Env {
bgdb: D1Database;
}
export const onRequestGet: PagesFunction<Env> = async (context) => {
try {
const { env, data } = context;
// 从数据库获取用户信息
const db = env.bgdb;
const userInfo = await db.prepare(`
SELECT id, phone, nickname, avatar_url, status
FROM users
WHERE id = ?
`).bind(data.user.userId).first();
if (!userInfo) {
return new Response(
JSON.stringify({
success: false,
message: '用户不存在'
}),
{
status: 404,
headers: {
'Content-Type': 'application/json',
},
}
);
}
//处理avatar_url
if (userInfo.avatar_url) {
userInfo.avatar_url = `${env.NEXT_PUBLIC_CF_IMAGES_DELIVERY_URL}/${userInfo.avatar_url}/public`;
}
return new Response(
JSON.stringify({
success: true,
data: userInfo
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
);
} catch (error) {
console.error(error);
return new Response(
JSON.stringify({
success: false,
message: '服务器错误'
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
};

View File

@@ -0,0 +1,107 @@
interface Env {
bgdb: D1Database;
}
export const onRequestPost: PagesFunction<Env> = async (context) => {
try {
const { env, data, request } = context;
// 解析请求体
const body = await request.json();
const { nickname, avatar_url } = body;
// 构建 SQL 更新语句和参数
let sql = 'UPDATE users SET updated_at = DATETIME(\'now\')';
const params = [];
// 如果有昵称更新
if (nickname !== undefined) {
if (typeof nickname !== 'string' || nickname.length > 32) {
return new Response(
JSON.stringify({
success: false,
message: '昵称格式不正确'
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
sql += ', nickname = ?';
params.push(nickname);
}
// 如果有头像更新
if (avatar_url !== undefined) {
if (typeof avatar_url !== 'string') {
return new Response(
JSON.stringify({
success: false,
message: '头像URL格式不正确'
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
sql += ', avatar_url = ?';
params.push(avatar_url);
}
// 添加 WHERE 条件
sql += ' WHERE id = ?';
params.push(data.user.userId);
// 执行更新
const result = await env.bgdb.prepare(sql).bind(...params).run();
if (!result.success) {
return new Response(
JSON.stringify({
success: false,
message: '更新失败'
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// 获取更新后的用户信息
const userInfo = await env.bgdb.prepare(`
SELECT id, phone, nickname, avatar_url, status
FROM users
WHERE id = ?
`).bind(data.user.userId).first();
//处理avatar_url
if (userInfo.avatar_url) {
userInfo.avatar_url = `${env.NEXT_PUBLIC_CF_IMAGES_DELIVERY_URL}/${userInfo.avatar_url}/public`;
}
return new Response(
JSON.stringify({
success: true,
data: userInfo
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
);
} catch (error) {
console.error(error);
return new Response(
JSON.stringify({
success: false,
message: '服务器错误'
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
};

View File

@@ -0,0 +1,24 @@
export const onRequestPost: PagesFunction<Env> = async (context) =>{
const { env, data } = context;
// 创建 FormData 对象
const formData = new FormData();
formData.append('requireSignedURLs', 'false');
formData.append('metadata', JSON.stringify({
user: data.user.userId
}));
// 调用 Cloudflare Images API 获取直接上传 URL
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/images/v2/direct_upload`, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.CF_API_TOKEN}`,
},
body: formData
});
const data1 = await response.json();
console.log('上传头像', data1);
//下发image前缀
return Response.json(data1.result);
}

125
functions/utils/sms.ts Normal file
View File

@@ -0,0 +1,125 @@
interface AliyunSMSConfig {
accessKeyId: string;
accessKeySecret: string;
signName: string;
templateCode: string;
}
// 辅助函数:生成随机字符串
function generateNonce(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
// 辅助函数:计算 HMAC-SHA256V3 使用 SHA256
async function calculateHmacSha256(message: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(message);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
messageData
);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}
export async function sendSMS(phone: string, code: string, config: AliyunSMSConfig) {
const API_URL = 'https://dysmsapi.aliyuncs.com/';
const API_VERSION = '2017-05-25';
// 准备请求参数
const params = {
AccessKeyId: config.accessKeyId,
Action: 'SendSms',
Format: 'JSON', // 明确指定返回 JSON 格式
PhoneNumbers: phone,
SignName: config.signName,
TemplateCode: config.templateCode,
TemplateParam: JSON.stringify({ code }),
Version: API_VERSION,
SignatureMethod: 'HMAC-SHA1',
SignatureVersion: '1.0',
SignatureNonce: generateNonce(16),
Timestamp: new Date().toISOString()
};
// 参数排序
const sortedParams = Object.keys(params)
.sort()
.reduce((acc, key) => ({
...acc,
[key]: params[key]
}), {});
// 构建签名字符串
const canonicalizedQueryString = Object.entries(sortedParams)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value as string)}`)
.join('&');
const stringToSign = `GET&${encodeURIComponent('/')}&${encodeURIComponent(canonicalizedQueryString)}`;
// 计算签名
const signature = await calculateHmacSha1(stringToSign, `${config.accessKeySecret}&`);
// 构建最终的 URL
const finalUrl = `${API_URL}?${canonicalizedQueryString}&Signature=${encodeURIComponent(signature)}`;
// 发送请求
const response = await fetch(finalUrl, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
const responseText = await response.text();
let responseData;
try {
responseData = JSON.parse(responseText);
} catch (e) {
console.error('Response:', responseText);
throw new Error('Invalid response format from SMS service');
}
//console.log(responseData, finalUrl)
if (!response.ok || responseData.Code !== 'OK') {
throw new Error(responseData.Message || 'SMS send failed');
}
return responseData;
}
// 辅助函数:计算 HMAC-SHA1
async function calculateHmacSha1(message: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(message);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
messageData
);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}

41
init-db.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# 文件名: init-db.sh
rm -rf .local-db
# 重新创建目录
mkdir -p .local-db
# 删除旧数据库(如果存在)
wrangler d1 delete friends --local
# 创建新数据库
wrangler d1 create friends --local --persist-to=.local-db
# 创建表
wrangler d1 execute friends --local --persist-to=.local-db --command="
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
phone VARCHAR(11) NOT NULL UNIQUE,
nickname VARCHAR(50),
avatar_url TEXT,
status INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);"
# 插入测试数据
wrangler d1 execute friends --local --persist-to=.local-db --command="
INSERT OR IGNORE INTO users (phone, nickname)
VALUES ('13800138000', '测试用户');"
# 验证表是否创建成功
echo "验证表结构:"
wrangler d1 execute friends --local --persist-to=.local-db --command="PRAGMA table_info(users);"
echo "验证数据:"
wrangler d1 execute friends --local --persist-to=.local-db --command="SELECT * FROM users;"
echo "数据库初始化完成。使用以下命令启动开发服务器:"
echo "wrangler pages dev --d1 bgdb=friends --persist-to=.local-db -- npm run dev"

View File

@@ -0,0 +1 @@
-- Migration number: 0001 2025-03-27T12:27:07.602Z

View File

@@ -0,0 +1 @@
-- Migration number: 0002 2025-03-28T06:06:16.353Z

313
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "chat-ui",
"version": "1.0.0",
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/dysmsapi20170525": "^3.1.1",
"@fontsource/audiowide": "^5.1.1",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
@@ -30,6 +32,7 @@
"react-dom": "^18.2.0",
"react-github-btn": "^1.4.0",
"react-markdown": "^9.0.3",
"react-router-dom": "^7.4.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
@@ -37,7 +40,8 @@
"tailwind-merge": "^2.6.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"wrangler": "^3.112.0"
"wrangler": "^3.112.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@shadcn/ui": "^0.0.4",
@@ -54,6 +58,114 @@
"vite": "^5.0.0"
}
},
"node_modules/@alicloud/credentials": {
"version": "2.4.2",
"resolved": "https://registry.npmmirror.com/@alicloud/credentials/-/credentials-2.4.2.tgz",
"integrity": "sha512-UbqUYlwOWKNxOemXM545HzQyCaChhyrne9cab4f67EqAkgrTjeMiTA7QK6sHtmcmJYowYQxxXoKPSe5GZstvbA==",
"dependencies": {
"@alicloud/tea-typescript": "^1.8.0",
"httpx": "^2.3.3",
"ini": "^1.3.5",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/dysmsapi20170525": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@alicloud/dysmsapi20170525/-/dysmsapi20170525-3.1.1.tgz",
"integrity": "sha512-UvrQo9p1b7A/JH209jPFLdtuYGywMrn4vWl48LwGxgZOH21i/LQXJKGhIUkeN9/CbdWsW709lkJ9kWvzmQZ5gQ==",
"dependencies": {
"@alicloud/endpoint-util": "^0.0.1",
"@alicloud/openapi-client": "^0.4.12",
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "^1.4.9"
}
},
"node_modules/@alicloud/endpoint-util": {
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz",
"integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/gateway-spi": {
"version": "0.0.8",
"resolved": "https://registry.npmmirror.com/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz",
"integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==",
"dependencies": {
"@alicloud/credentials": "^2",
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/openapi-client": {
"version": "0.4.13",
"resolved": "https://registry.npmmirror.com/@alicloud/openapi-client/-/openapi-client-0.4.13.tgz",
"integrity": "sha512-APi5NwzY6IMxGxM3bTehCzfFVsvY11ljbERhuO2Q4Lfemo4yF23ph38nXLmpv92exDmc4Xe93qKDhnVYMVEdiQ==",
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/gateway-spi": "^0.0.8",
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "1.4.9",
"@alicloud/tea-xml": "0.0.3"
}
},
"node_modules/@alicloud/openapi-client/node_modules/@alicloud/tea-util": {
"version": "1.4.9",
"resolved": "https://registry.npmmirror.com/@alicloud/tea-util/-/tea-util-1.4.9.tgz",
"integrity": "sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/openapi-util": {
"version": "0.3.2",
"resolved": "https://registry.npmmirror.com/@alicloud/openapi-util/-/openapi-util-0.3.2.tgz",
"integrity": "sha512-EC2JvxdcOgMlBAEG0+joOh2IB1um8CPz9EdYuRfTfd1uP8Yc9D8QRUWVGjP6scnj6fWSOaHFlit9H6PrJSyFow==",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "^1.3.0",
"kitx": "^2.1.0",
"sm3": "^1.0.3"
}
},
"node_modules/@alicloud/tea-typescript": {
"version": "1.8.0",
"resolved": "https://registry.npmmirror.com/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz",
"integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==",
"dependencies": {
"@types/node": "^12.0.2",
"httpx": "^2.2.6"
}
},
"node_modules/@alicloud/tea-typescript/node_modules/@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
},
"node_modules/@alicloud/tea-util": {
"version": "1.4.10",
"resolved": "https://registry.npmmirror.com/@alicloud/tea-util/-/tea-util-1.4.10.tgz",
"integrity": "sha512-VEsXWP2dlJLvsY2THj+sH++zwxQRz3Y5BQ8EkfnFems36RkngQKYOLsoto5nR6ej1Gf6I+0IOgBXrkRdpNCQ1g==",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"@darabonba/typescript": "^1.0.0",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/tea-xml": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/@alicloud/tea-xml/-/tea-xml-0.0.3.tgz",
"integrity": "sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==",
"dependencies": {
"@alicloud/tea-typescript": "^1",
"@types/xml2js": "^0.4.5",
"xml2js": "^0.6.0"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -439,6 +551,19 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@darabonba/typescript": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@darabonba/typescript/-/typescript-1.0.3.tgz",
"integrity": "sha512-/y2y6wf5TsxD7pCPIm0OvTC+5qV0Tk7HQYxwpIuWRLXQLB0CRDvr6qk4bR6rTLO/JglJa8z2uCGZsaLYpQNqFQ==",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"httpx": "^2.3.2",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"xml2js": "^0.6.2"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.3.1.tgz",
@@ -2405,6 +2530,11 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz",
@@ -2533,6 +2663,14 @@
"resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
},
"node_modules/@types/xml2js": {
"version": "0.4.14",
"resolved": "https://registry.npmmirror.com/@types/xml2js/-/xml2js-0.4.14.tgz",
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -3988,6 +4126,28 @@
"node": ">=8.0.0"
}
},
"node_modules/httpx": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/httpx/-/httpx-2.3.3.tgz",
"integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==",
"dependencies": {
"@types/node": "^20",
"debug": "^4.1.1"
}
},
"node_modules/httpx/node_modules/@types/node": {
"version": "20.17.27",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.17.27.tgz",
"integrity": "sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/httpx/node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"node_modules/human-signals": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz",
@@ -4051,6 +4211,11 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/inline-style-parser": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
@@ -4309,6 +4474,14 @@
"node": ">= 12"
}
},
"node_modules/kitx": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/kitx/-/kitx-2.2.0.tgz",
"integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==",
"dependencies": {
"@types/node": "^22.5.4"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -4334,6 +4507,11 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
@@ -5386,6 +5564,25 @@
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmmirror.com/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6060,6 +6257,52 @@
}
}
},
"node_modules/react-router": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.4.0.tgz",
"integrity": "sha512-Y2g5ObjkvX3VFeVt+0CIPuYd9PpgqCslG7ASSIdN73LwA1nNWzcMLaoMRJfP3prZFI92svxFwbn7XkLJ+UPQ6A==",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.4.0.tgz",
"integrity": "sha512-VlksBPf3n2bijPvnA7nkTsXxMAKOj+bWp4R9c3i+bnwlSOFAGOkJkKhzy/OsRkWaBMICqcAl1JDzh9ZSOze9CA==",
"dependencies": {
"react-router": "7.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"engines": {
"node": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -6432,6 +6675,11 @@
}
]
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -6449,6 +6697,11 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.33.5.tgz",
@@ -6549,6 +6802,11 @@
"node": ">=8"
}
},
"node_modules/sm3": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/sm3/-/sm3-1.0.3.tgz",
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g=="
},
"node_modules/sonner": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.0.tgz",
@@ -6918,6 +7176,11 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
@@ -7840,6 +8103,26 @@
}
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -7876,6 +8159,34 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.3.tgz",
"integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -8,6 +8,8 @@
"preview": "vite preview"
},
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/dysmsapi20170525": "^3.1.1",
"@fontsource/audiowide": "^5.1.1",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
@@ -30,6 +32,7 @@
"react-dom": "^18.2.0",
"react-github-btn": "^1.4.0",
"react-markdown": "^9.0.3",
"react-router-dom": "^7.4.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
@@ -37,7 +40,8 @@
"tailwind-merge": "^2.6.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"wrangler": "^3.112.0"
"wrangler": "^3.112.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@shadcn/ui": "^0.0.4",

View File

@@ -1,13 +1,11 @@
import ChatUI from './components/ChatUI'
import './App.css'
import Layout from './components/Layout'
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
function App() {
console.log("App rendering"); // 添加日志
return (
<Layout>
<ChatUI />
</Layout>
)
<RouterProvider router={router} />
);
}
export default App
export default App;

View File

@@ -0,0 +1,21 @@
import { Navigate, useLocation } from 'react-router-dom';
export default function AuthGuard({ children }) {
//判断环境变量中的AUTH_ACCESS是否为1开启权限校验
const authAccess = import.meta.env.AUTH_ACCESS;
if (authAccess === '1') {
const location = useLocation();
const token = localStorage.getItem('token');
if (!token && location.pathname !== '/login') {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (token && location.pathname === '/login') {
return <Navigate to="/" replace />;
}
}
return children;
}

View File

@@ -0,0 +1,22 @@
import { Outlet, useNavigate } from 'react-router-dom';
export default function BasicLayout() {
const navigate = useNavigate();
const handleLogout = () => {
localStorage.removeItem('token');
navigate('/login');
};
return (
<div className="layout">
<header className="header">
<div className="logo">AI Chat</div>
<button onClick={handleLogout}>退</button>
</header>
<main className="main">
<Outlet />
</main>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { cn } from '../lib/utils';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverContent,

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { request } from '@/utils/request';
import {
Tooltip,
TooltipContent,
@@ -17,98 +17,13 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import { SharePoster } from '@/components/SharePoster';
import { MembersManagement } from '@/components/MembersManagement';
import { SharePoster } from '@/pages/chat/components/SharePoster';
import { MembersManagement } from '@/pages/chat/components/MembersManagement';
import Sidebar from './Sidebar';
import { AdBanner, AdBannerMobile } from './AdSection';
// 使用本地头像数据,避免外部依赖
const getAvatarData = (name: string) => {
const colors = ['#1abc9c', '#3498db', '#9b59b6', '#f1c40f', '#e67e22'];
const index = (name.charCodeAt(0) + (name.charCodeAt(1) || 0 )) % colors.length;
return {
backgroundColor: colors[index],
text: name[0],
};
};
import { useUserStore } from '@/store/userStore';
import { getAvatarData } from '@/utils/avatar';
// 单个完整头像
const SingleAvatar = ({ user }: { user: User | AICharacter }) => {
// 如果有头像就使用头像,否则使用默认的文字头像
if ('avatar' in user && user.avatar) {
return (
<div className="w-full h-full">
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
</div>
);
}
const avatarData = getAvatarData(user.name);
return (
<div
className="w-full h-full flex items-center justify-center text-xs text-white font-medium"
style={{ backgroundColor: avatarData.backgroundColor }}
>
{avatarData.text}
</div>
);
};
// 左右分半头像
const HalfAvatar = ({ user, isFirst }: { user: User, isFirst: boolean }) => {
if ('avatar' in user && user.avatar) {
return (
<div
className="w-1/2 h-full"
style={{
borderRight: isFirst ? '1px solid white' : 'none'
}}
>
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
</div>
);
}
const avatarData = getAvatarData(user.name);
return (
<div
className="w-1/2 h-full flex items-center justify-center text-xs text-white font-medium"
style={{
backgroundColor: avatarData.backgroundColor,
borderRight: isFirst ? '1px solid white' : 'none'
}}
>
{avatarData.text}
</div>
);
};
// 四分之一头像
const QuarterAvatar = ({ user, index }: { user: User, index: number }) => {
if ('avatar' in user && user.avatar) {
return (
<div
className="aspect-square"
style={{
borderRight: index % 2 === 0 ? '1px solid white' : 'none',
borderBottom: index < 2 ? '1px solid white' : 'none'
}}
>
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
</div>
);
}
const avatarData = getAvatarData(user.name);
return (
<div
className="aspect-square flex items-center justify-center text-[8px] text-white font-medium"
style={{
backgroundColor: avatarData.backgroundColor,
borderRight: index % 2 === 0 ? '1px solid white' : 'none',
borderBottom: index < 2 ? '1px solid white' : 'none'
}}
>
{avatarData.text}
</div>
);
};
// 修改 KaTeXStyle 组件
const KaTeXStyle = () => (
@@ -137,10 +52,10 @@ const KaTeXStyle = () => (
`}} />
);
// Vite环境变量访问方式
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
const ChatUI = () => {
const userStore = useUserStore();
//获取url参数
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get('id')? parseInt(urlParams.get('id')!) : 0;
@@ -182,7 +97,7 @@ const ChatUI = () => {
const initData = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/init`);
const response = await request(`/api/init`);
if (!response.ok) {
throw new Error('初始化数据失败');
}
@@ -203,9 +118,28 @@ const ChatUI = () => {
setGroupAiCharacters(groupAiCharacters);
const allNames = groupAiCharacters.map(character => character.name);
allNames.push('user');
let avatar_url = null;
let nickname = '我';
setAllNames(allNames);
if (data.user && data.user != null) {
const response1 = await request('/api/user/info');
const userInfo = await response1.json();
//设置store
userStore.setUserInfo(userInfo.data);
avatar_url = userInfo.data.avatar_url;
nickname = userInfo.data.nickname;
} else {
// 设置空的用户信息
userStore.setUserInfo({
id: 0,
phone: '',
nickname: nickname,
avatar_url: null,
status: 0
});
}
setUsers([
{ id: 1, name: "我" },
{ id: 1, name: nickname, avatar: avatar_url },
...groupAiCharacters
]);
} catch (error) {
@@ -217,7 +151,7 @@ const ChatUI = () => {
initData();
// 标记为已初始化
isInitialized.current = true;
}, []); // 依赖数组保持为空
}, [userStore]);
useEffect(() => {
scrollToBottom();
@@ -237,6 +171,16 @@ const ChatUI = () => {
};
}, []);
// 添加一个新的 useEffect 来监听 userStore.userInfo 的变化
useEffect(() => {
if (userStore.userInfo && users.length > 0) {
setUsers(prev => [
{ id: 1, name: userStore.userInfo.nickname, avatar: userStore.userInfo.avatar_url? userStore.userInfo.avatar_url : null },
...prev.slice(1) // 保留其他 AI 角色
]);
}
}, [userStore.userInfo]); // 当 userInfo 变化时更新 users
// 4. 工具函数
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -292,12 +236,12 @@ const ChatUI = () => {
// 构建历史消息数组
let messageHistory = messages.map(msg => ({
role: 'user',
content: msg.sender.name == "我" ? 'user' + msg.content : msg.sender.name + '' + msg.content,
content: msg.sender.name == userStore.userInfo.nickname ? 'user' + msg.content : msg.sender.name + '' + msg.content,
name: msg.sender.name
}));
let selectedGroupAiCharacters = groupAiCharacters;
if (!isGroupDiscussionMode) {
const shedulerResponse = await fetch(`${API_BASE_URL}/api/scheduler`, {
const shedulerResponse = await request(`/api/scheduler`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -325,7 +269,7 @@ const ChatUI = () => {
setMessages(prev => [...prev, aiMessage]);
try {
const response = await fetch(`${API_BASE_URL}/api/chat`, {
const response = await request(`/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -466,34 +410,6 @@ const ChatUI = () => {
//进行跳转到?id=index
window.location.href = `?id=${index}`;
return;
/*
//跳转后,关闭当前页面
setSelectedGroupIndex(index);
const newGroup = groups[index];
setGroup(newGroup);
// 重新生成当前群组的 AI 角色,并按照 members 数组的顺序排序
const newGroupAiCharacters = generateAICharacters(newGroup.name)
.filter(character => newGroup.members.includes(character.id))
.sort((a, b) => {
return newGroup.members.indexOf(a.id) - newGroup.members.indexOf(b.id);
});
// 更新用户列表
setUsers([
{ id: 1, name: "我" },
...newGroupAiCharacters
]);
setIsGroupDiscussionMode(newGroup.isGroupDiscussionMode);
// 重置消息
setMessages([]);
// 可选:关闭侧边栏(在移动设备上)
if (window.innerWidth < 768) {
setSidebarOpen(false);
}
*/
};
return (
@@ -542,7 +458,7 @@ const ChatUI = () => {
<Tooltip>
<TooltipTrigger>
<Avatar className="w-7 h-7 border-2 border-white">
{'avatar' in user && user.avatar ? (
{'avatar' in user && user.avatar && user.avatar !== null ? (
<AvatarImage src={user.avatar} />
) : (
<AvatarFallback style={{ backgroundColor: avatarData.backgroundColor, color: 'white' }}>
@@ -581,8 +497,8 @@ const ChatUI = () => {
<div className="space-y-4">
{messages.map((message) => (
<div key={message.id}
className={`flex items-start gap-2 ${message.sender.name === "我" ? "justify-end" : ""}`}>
{message.sender.name !== "我" && (
className={`flex items-start gap-2 ${message.sender.name === userStore.userInfo.nickname ? "justify-end" : ""}`}>
{message.sender.name !== userStore.userInfo.nickname && (
<Avatar>
{'avatar' in message.sender && message.sender.avatar ? (
<AvatarImage src={message.sender.avatar} className="w-10 h-10" />
@@ -593,16 +509,16 @@ const ChatUI = () => {
)}
</Avatar>
)}
<div className={message.sender.name === "我" ? "text-right" : ""}>
<div className={message.sender.name === userStore.userInfo.nickname ? "text-right" : ""}>
<div className="text-sm text-gray-500">{message.sender.name}</div>
<div className={`mt-1 p-3 rounded-lg shadow-sm chat-message ${
message.sender.name === "我" ? "bg-blue-500 text-white text-left" : "bg-white"
message.sender.name === userStore.userInfo.nickname ? "bg-blue-500 text-white text-left" : "bg-white"
}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
className={`prose dark:prose-invert max-w-none ${
message.sender.name === "我" ? "text-white [&_*]:text-white" : ""
message.sender.name === userStore.userInfo.nickname ? "text-white [&_*]:text-white" : ""
}
[&_h2]:py-1
[&_h2]:m-0
@@ -640,7 +556,7 @@ const ChatUI = () => {
)}
</div>
</div>
{message.sender.name === "我" && (
{message.sender.name === userStore.userInfo.nickname && (
<Avatar>
{'avatar' in message.sender && message.sender.avatar ? (
<AvatarImage src={message.sender.avatar} className="w-10 h-10" />

View File

@@ -4,8 +4,8 @@ import { MessageSquareIcon, PlusCircleIcon, MenuIcon, PanelLeftCloseIcon } from
import { cn } from "@/lib/utils";
import GitHubButton from 'react-github-btn';
import '@fontsource/audiowide';
//import { groups } from "@/config/groups";
import { AdSection } from './AdSection';
import { UserSection } from './UserSection';
import {
Tooltip,
TooltipContent,
@@ -123,6 +123,9 @@ const Sidebar = ({ isOpen, toggleSidebar, selectedGroupIndex = 0, onSelectGroup,
{/* 广告位 */}
<AdSection isOpen={isOpen} />
{/* 用户信息模块 */}
<UserSection isOpen={isOpen} />
{/* GitHub Star Button - 只在侧边栏打开时显示,放在底部 */}
<div className="px-3 py-2 mt-auto">
{/* 标题移至底部 */}
@@ -141,7 +144,7 @@ const Sidebar = ({ isOpen, toggleSidebar, selectedGroupIndex = 0, onSelectGroup,
</div>
{isOpen && (
<div className="flex items-center justify-left">
<div className="flex items-center justify-left h-8">
<GitHubButton
href="https://github.com/maojindao55/botgroup.chat"
data-color-scheme="no-preference: light; light: light; dark: light;"

View File

@@ -0,0 +1,220 @@
import React, { useState, useEffect, useRef } from 'react';
import { cn } from "@/lib/utils";
import { Edit2Icon, LogOutIcon, CheckIcon, XIcon } from 'lucide-react';
import { request } from '@/utils/request';
import { useUserStore } from '@/store/userStore';
import { getAvatarData } from '@/utils/avatar';
interface UserSectionProps {
isOpen: boolean;
}
export const UserSection: React.FC<UserSectionProps> = ({ isOpen }) => {
const [isHovering, setIsHovering] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [newNickname, setNewNickname] = useState('');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const userStore = useUserStore();
// 添加更新昵称的函数
const updateNickname = async () => {
if (!newNickname.trim()) return;
try {
setIsLoading(true);
const response = await request('/api/user/update', {
method: 'POST',
body: JSON.stringify({ nickname: newNickname.trim() })
});
const { data } = await response.json();
console.log('更新用户信息', data);
//更新用户信息
userStore.setUserInfo(data);
setIsEditing(false);
} catch (error) {
console.error('更新昵称失败:', error);
} finally {
setIsLoading(false);
}
};
// 添加上传头像的处理函数
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setUploadingAvatar(true);
// 1. 首先从后端获取上传 URL
const response = await request('/api/user/upload', {
method: 'POST'
});
const { uploadURL, id } = await response.json();
// 2. 上传图片到 Cloudflare Images
const formData = new FormData();
formData.append('file', file); // 使用 'file' 作为字段名
await fetch(uploadURL, {
method: 'POST',
body: formData
});
// 3. 更新用户头像信息
const updateResponse = await request('/api/user/update', {
method: 'POST',
body: JSON.stringify({ avatar_url: id })
});
const { data } = await updateResponse.json();
userStore.setUserInfo(data);
} catch (error) {
console.error('上传头像失败:', error);
} finally {
setUploadingAvatar(false);
}
};
if (!isOpen || !userStore.userInfo || !userStore.userInfo.status) return null;
return (
<div
className={cn(
"px-3 py-3 border-t border-b border-border/40 h-20",
"flex items-center gap-3 hover:bg-accent/50 transition-colors"
)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
{/* 头像区域 */}
<div className="relative group cursor-pointer">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleAvatarUpload}
/>
<div
className="w-10 h-10 rounded-full flex items-center justify-center shadow-sm overflow-hidden"
style={{ backgroundColor: getAvatarData(userStore.userInfo?.nickname || '我').backgroundColor }}
onClick={() => !uploadingAvatar && fileInputRef.current?.click()}
>
{uploadingAvatar ? (
<div className="flex items-center justify-center w-full h-full bg-black/20">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
) : userStore.userInfo?.avatar_url ? (
<img
src={`${userStore.userInfo.avatar_url}`}
alt="avatar"
className="w-full h-full object-cover"
/>
) : (
<span
className="text-base font-medium text-white"
>
{getAvatarData(userStore.userInfo?.nickname || '我').text}
</span>
)}
</div>
{/* 头像hover效果 */}
<div
className={cn(
"absolute inset-0 rounded-full bg-black/40 flex items-center justify-center transition-opacity",
uploadingAvatar ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
)}
onClick={() => !uploadingAvatar && fileInputRef.current?.click()}
>
<Edit2Icon className="w-4 h-4 text-white" />
</div>
</div>
{/* 用户信息区域 */}
<div className="flex flex-col relative flex-1">
<div className="flex items-center group cursor-pointer">
{isEditing ? (
<div className="flex flex-col">
<input
type="text"
value={newNickname}
onChange={(e) => setNewNickname(e.target.value)}
className="text-sm px-2 border rounded-md w-full"
placeholder={userStore.userInfo?.nickname || '输入新昵称'}
onKeyDown={(e) => {
if (e.key === 'Enter') updateNickname();
if (e.key === 'Escape') setIsEditing(false);
}}
autoFocus
/>
<div className="flex items-center gap-1">
<button
onClick={updateNickname}
className="p-1 hover:bg-emerald-50 rounded-md transition-colors"
title="保存"
>
<CheckIcon className="w-4 h-4 text-emerald-600 hover:text-emerald-500" />
</button>
<button
onClick={() => setIsEditing(false)}
className="p-1 hover:bg-rose-50 rounded-md transition-colors"
title="取消"
>
<XIcon className="w-4 h-4 text-rose-600 hover:text-rose-500" />
</button>
</div>
</div>
) : (
<>
<span className="text-sm font-semibold group-hover:text-primary transition-colors">
{isLoading ? '加载中...' : userStore.userInfo?.nickname || '游客用户'}
</span>
<Edit2Icon
className={cn(
"w-3 h-3 text-muted-foreground/50",
"opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
)}
onClick={() => {
setIsEditing(true);
setNewNickname(userStore.userInfo?.nickname || '');
}}
/>
</>
)}
</div>
{/* 退出登录按钮 */}
{!isEditing && (
<div
className={cn(
"flex items-center gap-0.5 mt-1 text-xs text-muted-foreground/70",
"hover:text-rose-500 transition-all duration-200 group",
"rounded-md cursor-pointer"
)}
onClick={() => {
localStorage.removeItem('token');
window.location.href = '/login';
}}
>
<LogOutIcon
className={cn(
"w-3 h-3",
"group-hover:animate-pulse"
)}
/>
<span className="group-hover:tracking-wide transition-all duration-200">
退
</span>
</div>
)}
</div>
</div>
);
};

7
src/pages/chat/index.tsx Normal file
View File

@@ -0,0 +1,7 @@
import ChatUI from './components/ChatUI';
export default function Chat() {
return (
<ChatUI />
);
}

View File

@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { request } from '@/utils/request';
interface PhoneLoginProps {
onLogin: (phone: string, code: string) => void;
}
const PhoneLogin: React.FC<PhoneLoginProps> = ({ handleLoginSuccess }) => {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [isLoading, setIsLoading] = useState(false);
// 发送验证码
const handleSendCode = async () => {
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
alert('请输入正确的手机号');
return;
}
//setIsLoading(true);
try {
const response = await fetch(`/api/sendcode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone }),
});
if (!response.ok) {
throw new Error('发送验证码失败');
}
// 开始倒计时
setCountdown(60);
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
} catch (error) {
console.error('发送验证码失败:', error);
alert('发送验证码失败,请重试');
} finally {
setIsLoading(false);
}
};
// 提交登录
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!phone || !code) {
alert('请输入手机号和验证码');
return;
}
setIsLoading(true);
try {
const response = await request(`/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone, code }),
});
const data = await response.json();
console.log(data);
if (!response.ok) {
throw new Error(data.message || '登录失败');
}
//执行登录成功回调
handleLoginSuccess(data.data.token);
} catch (error) {
console.error('登录失败:', error);
alert(error instanceof Error ? error.message : '登录失败,请重试');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 bg-white flex items-center justify-center">
<div className="w-full max-w-md p-8">
{/* Logo */}
<div className="flex items-center justify-center mb-4">
<span style={{fontFamily: 'Audiowide, system-ui', color: '#ff6600'}} className="text-3xl ml-2">botgroup.chat</span>
</div>
<div className="text-gray-500 mb-4 text-center">
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<div className="flex items-center border rounded-lg p-3 h-[46px] focus-within:border-[#ff6600]">
<span className="text-gray-400 mr-2">+86</span>
<Input
type="tel"
placeholder="请输入手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
maxLength={11}
className="border-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none p-0"
/>
</div>
</div>
<div>
<div className="flex gap-3">
<Input
type="text"
placeholder="请输入验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
className="border rounded-lg p-3 h-[46px] focus:border-[#ff6600] focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none"
/>
<Button
type="button"
onClick={handleSendCode}
disabled={countdown > 0 || isLoading}
className="bg-white text-[#ff6600] border border-[#ff6600] hover:bg-[#ff6600] hover:text-white rounded-lg px-6 h-[46px]"
>
{countdown > 0 ? `${countdown}秒后重试` : '发送验证码'}
</Button>
</div>
</div>
<Button
type="submit"
className="w-full bg-[#ff6600] hover:bg-[#e65c00] text-white rounded-lg py-3"
disabled={isLoading}
>
{isLoading ? (
<div className="w-4 h-4 mr-2 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : '登录'}
</Button>
</form>
</div>
</div>
);
};
export default PhoneLogin;

25
src/pages/login/index.jsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import PhoneLogin from './comonents/PhoneLogin';
export default function Login() {
const navigate = useNavigate();
const handleLoginSuccess = (token) => {
localStorage.setItem('token', token);
navigate('/');
};
React.useEffect(() => {
const isLogin = localStorage.getItem('token');
if (isLogin) {
window.location.href = '/'; // 由于是 Vite 多页面,这里使用 window.location.href
}
}, []);
return (
<div className="login-container">
<PhoneLogin handleLoginSuccess={handleLoginSuccess} />
</div>
);
}

26
src/routes.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { createBrowserRouter } from 'react-router-dom';
import Login from './pages/login';
import Chat from './pages/chat';
import BasicLayout from './layouts/BasicLayout';
import AuthGuard from './components/AuthGuard';
export const router = createBrowserRouter([
{
path: '/login',
element: <Login />,
},
{
path: '/',
element: (
<AuthGuard>
<BasicLayout />
</AuthGuard>
),
children: [
{
path: '',
element: <Chat />,
},
],
},
]);

25
src/store/userStore.ts Normal file
View File

@@ -0,0 +1,25 @@
import { create } from 'zustand'
interface UserInfo {
id: number;
phone: string;
nickname: string;
avatar_url: string | null;
status: number;
}
interface UserStore {
userInfo: UserInfo;
setUserInfo: (userInfo: UserInfo) => void;
}
export const useUserStore = create<UserStore>((set) => ({
userInfo: {
id: 0,
phone: '',
nickname: '',
avatar_url: null,
status: 0
},
setUserInfo: (userInfo: UserInfo) => set({ userInfo })
}));

97
src/utils/avatar.ts Normal file
View File

@@ -0,0 +1,97 @@
import React from 'react';
interface User {
id: number | string;
name: string;
avatar?: string;
}
interface AICharacter {
id: string;
name: string;
personality: string;
model: string;
avatar?: string;
custom_prompt?: string;
tags?: string[];
}
export const getAvatarData = (name: string) => {
const colors = ['#1abc9c', '#3498db', '#9b59b6', '#f1c40f', '#e67e22'];
const index = (name.charCodeAt(0) + (name.charCodeAt(1) || 0)) % colors.length;
return {
backgroundColor: colors[index],
text: name[0],
};
};
// 获取单个头像的样式和内容
export const getSingleAvatarData = (user: User | AICharacter) => {
if ('avatar' in user && user.avatar) {
return {
type: 'image',
src: user.avatar,
alt: user.name,
className: 'w-full h-full object-cover'
};
}
const avatarData = getAvatarData(user.name);
return {
type: 'text',
text: avatarData.text,
className: 'w-full h-full flex items-center justify-center text-xs text-white font-medium',
style: { backgroundColor: avatarData.backgroundColor }
};
};
// 获取半头像的样式和内容
export const getHalfAvatarData = (user: User, isFirst: boolean) => {
if ('avatar' in user && user.avatar) {
return {
type: 'image',
src: user.avatar,
alt: user.name,
className: 'w-full h-full object-cover',
containerStyle: {
borderRight: isFirst ? '1px solid white' : 'none'
}
};
}
const avatarData = getAvatarData(user.name);
return {
type: 'text',
text: avatarData.text,
className: 'w-1/2 h-full flex items-center justify-center text-xs text-white font-medium',
style: {
backgroundColor: avatarData.backgroundColor,
borderRight: isFirst ? '1px solid white' : 'none'
}
};
};
// 获取四分之一头像的样式和内容
export const getQuarterAvatarData = (user: User, index: number) => {
if ('avatar' in user && user.avatar) {
return {
type: 'image',
src: user.avatar,
alt: user.name,
className: 'w-full h-full object-cover',
containerStyle: {
borderRight: index % 2 === 0 ? '1px solid white' : 'none',
borderBottom: index < 2 ? '1px solid white' : 'none'
}
};
}
const avatarData = getAvatarData(user.name);
return {
type: 'text',
text: avatarData.text,
className: 'aspect-square flex items-center justify-center text-[8px] text-white font-medium',
style: {
backgroundColor: avatarData.backgroundColor,
borderRight: index % 2 === 0 ? '1px solid white' : 'none',
borderBottom: index < 2 ? '1px solid white' : 'none'
}
};
};

38
src/utils/request.ts Normal file
View File

@@ -0,0 +1,38 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
export async function request(url: string, options: RequestInit = {}) {
const token = localStorage.getItem('token');
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers,
});
// 如果返回 401清除 token 并跳转到登录页
if (response.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
throw new Error('Request failed');
}
return response;
} catch (error) {
// 如果是网络错误或其他错误,也可以处理
console.error('Request error:', error);
throw error;
}
}

View File

@@ -1,3 +0,0 @@
[observability]
enabled = true
head_sampling_rate = 1 # optional. default = 1.

9
wrangler.toml Normal file
View File

@@ -0,0 +1,9 @@
[[kv_namespaces]]
binding = "bgkv"
id = "cbd11575c3504e6bb043c1c250d2f7ed"
preview_id = "cbd11575c3504e6bb043c1c250d2f7ed" # 本地开发环境使用
[[d1_databases]]
binding = "bgdb" # available in your Worker on env.DB
database_name = "friends"
database_id = "f68e9fa5-4aea-45db-9516-2d7052e936fa"