2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
13
friends.sql
Normal file
13
friends.sql
Normal 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');
|
||||
80
functions/api/_middleware.js
Normal file
80
functions/api/_middleware.js
Normal 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'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
196
functions/api/login.ts
Normal 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
85
functions/api/sendCode.ts
Normal 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
32
functions/api/test-db.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
64
functions/api/user/info.ts
Normal file
64
functions/api/user/info.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
107
functions/api/user/update.ts
Normal file
107
functions/api/user/update.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
24
functions/api/user/upload.ts
Normal file
24
functions/api/user/upload.ts
Normal 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
125
functions/utils/sms.ts
Normal 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-SHA256(V3 使用 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
41
init-db.sh
Normal 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"
|
||||
1
migrations/0001_init_users.sql
Normal file
1
migrations/0001_init_users.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Migration number: 0001 2025-03-27T12:27:07.602Z
|
||||
1
migrations/0002_create_users.sql
Normal file
1
migrations/0002_create_users.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Migration number: 0002 2025-03-28T06:06:16.353Z
|
||||
313
package-lock.json
generated
313
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
src/App.tsx
14
src/App.tsx
@@ -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;
|
||||
21
src/components/AuthGuard.jsx
Normal file
21
src/components/AuthGuard.jsx
Normal 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;
|
||||
}
|
||||
22
src/layouts/BasicLayout.tsx
Normal file
22
src/layouts/BasicLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -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" />
|
||||
@@ -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;"
|
||||
220
src/pages/chat/components/UserSection.tsx
Normal file
220
src/pages/chat/components/UserSection.tsx
Normal 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
7
src/pages/chat/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import ChatUI from './components/ChatUI';
|
||||
|
||||
export default function Chat() {
|
||||
return (
|
||||
<ChatUI />
|
||||
);
|
||||
}
|
||||
157
src/pages/login/comonents/PhoneLogin.tsx
Normal file
157
src/pages/login/comonents/PhoneLogin.tsx
Normal 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
25
src/pages/login/index.jsx
Normal 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
26
src/routes.tsx
Normal 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
25
src/store/userStore.ts
Normal 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
97
src/utils/avatar.ts
Normal 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
38
src/utils/request.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
[observability]
|
||||
enabled = true
|
||||
head_sampling_rate = 1 # optional. default = 1.
|
||||
9
wrangler.toml
Normal file
9
wrangler.toml
Normal 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"
|
||||
Reference in New Issue
Block a user