1
web-app/.env.development
Normal file
1
web-app/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8888
|
||||
1
web-app/.env.production
Normal file
1
web-app/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=https://your-production-api.com
|
||||
8
web-app/.vite/deps/_metadata.json
Normal file
8
web-app/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "137c6446",
|
||||
"configHash": "6b4f1a36",
|
||||
"lockfileHash": "ed4ec364",
|
||||
"browserHash": "4a9d650d",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
web-app/.vite/deps/package.json
Normal file
3
web-app/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
3
web-app/.vite/deps_temp_7925bc02/package.json
Normal file
3
web-app/.vite/deps_temp_7925bc02/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
55
web-app/README.md
Normal file
55
web-app/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 云酒馆前端
|
||||
|
||||
现代化的 SillyTavern UI,采用 Glassmorphism 风格和深色主题。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- React 18 + TypeScript
|
||||
- Tailwind CSS
|
||||
- Vite
|
||||
- React Router
|
||||
- Lucide Icons
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # 可复用组件
|
||||
│ ├── Navbar.tsx
|
||||
│ ├── Sidebar.tsx
|
||||
│ ├── ChatArea.tsx
|
||||
│ └── ...
|
||||
├── pages/ # 页面组件
|
||||
│ ├── HomePage.tsx
|
||||
│ ├── CharacterMarket.tsx
|
||||
│ ├── CharacterManagePage.tsx
|
||||
│ ├── PresetManagePage.tsx
|
||||
│ └── ...
|
||||
├── App.tsx # 路由配置
|
||||
├── main.tsx # 入口文件
|
||||
└── index.css # 全局样式
|
||||
```
|
||||
|
||||
## 设计系统
|
||||
|
||||
- 主色调: #7C3AED (紫色)
|
||||
- 次要色: #A78BFA (淡紫色)
|
||||
- 强调色: #F97316 (橙色)
|
||||
- 字体: Inter
|
||||
- 风格: Glassmorphism + 深色主题
|
||||
|
||||
详见 [设计系统文档](../docs/design-system/)
|
||||
15
web-app/index.html
Normal file
15
web-app/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>SillyTavern - Modern UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4652
web-app/package-lock.json
generated
Normal file
4652
web-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
web-app/package.json
Normal file
32
web-app/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "sillytavern-modern-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.5",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
6
web-app/postcss.config.js
Normal file
6
web-app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
36
web-app/src/App.tsx
Normal file
36
web-app/src/App.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {BrowserRouter, Route, Routes} from 'react-router-dom'
|
||||
import HomePage from './pages/HomePage'
|
||||
import CharacterMarket from './pages/CharacterMarket'
|
||||
import CharacterDetail from './pages/CharacterDetail'
|
||||
import CharacterDetailPage from './pages/CharacterDetailPage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
import CharacterManagePage from './pages/CharacterManagePage'
|
||||
import PresetManagePage from './pages/PresetManagePage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/market" element={<CharacterMarket />} />
|
||||
<Route path="/character/:id" element={<CharacterDetail />} />
|
||||
<Route path="/my-character/:id" element={<CharacterDetailPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/characters" element={<CharacterManagePage />} />
|
||||
<Route path="/presets" element={<PresetManagePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
114
web-app/src/api/aiConfig.ts
Normal file
114
web-app/src/api/aiConfig.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface AIConfig {
|
||||
id: number
|
||||
name: string
|
||||
provider: 'openai' | 'anthropic' | 'custom'
|
||||
baseUrl: string // 注意:后端返回的是 baseUrl 而不是 baseURL
|
||||
apiKey: string
|
||||
models?: string[]
|
||||
defaultModel: string
|
||||
settings?: Record<string, any>
|
||||
isActive: boolean
|
||||
isDefault: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateAIConfigRequest {
|
||||
name: string
|
||||
provider: 'openai' | 'anthropic' | 'custom'
|
||||
baseUrl: string // 注意:后端使用 baseUrl 而不是 baseURL
|
||||
apiKey: string
|
||||
defaultModel?: string
|
||||
settings?: Record<string, any>
|
||||
isActive?: boolean
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateAIConfigRequest {
|
||||
name?: string
|
||||
provider?: 'openai' | 'anthropic' | 'custom'
|
||||
baseUrl?: string // 注意:后端使用 baseUrl 而不是 baseURL
|
||||
apiKey?: string
|
||||
defaultModel?: string
|
||||
settings?: Record<string, any>
|
||||
isActive?: boolean
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
ownedBy: string
|
||||
}
|
||||
|
||||
export interface GetModelsRequest {
|
||||
baseUrl: string // 注意:后端使用 baseUrl 而不是 baseURL
|
||||
apiKey: string
|
||||
provider: 'openai' | 'anthropic' | 'custom'
|
||||
}
|
||||
|
||||
export interface GetModelsResponse {
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
export interface TestAIConfigRequest {
|
||||
baseUrl: string // 注意:后端使用 baseUrl 而不是 baseURL
|
||||
apiKey: string
|
||||
provider: 'openai' | 'anthropic' | 'custom'
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface TestAIConfigResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
latency: number
|
||||
}
|
||||
|
||||
export interface AIConfigListResponse {
|
||||
list: AIConfig[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const aiConfigApi = {
|
||||
// 创建AI配置
|
||||
createAIConfig: (data: CreateAIConfigRequest) => {
|
||||
return apiClient.post<AIConfig>('/app/ai-config', data)
|
||||
},
|
||||
|
||||
// 获取AI配置列表
|
||||
getAIConfigList: () => {
|
||||
return apiClient.get<AIConfigListResponse>('/app/ai-config')
|
||||
},
|
||||
|
||||
// 更新AI配置
|
||||
updateAIConfig: (id: number, data: UpdateAIConfigRequest) => {
|
||||
return apiClient.put<AIConfig>(`/app/ai-config/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除AI配置
|
||||
deleteAIConfig: (id: number) => {
|
||||
return apiClient.delete<null>(`/app/ai-config/${id}`)
|
||||
},
|
||||
|
||||
// 获取模型列表
|
||||
getModels: (data: GetModelsRequest) => {
|
||||
return apiClient.post<GetModelsResponse>('/app/ai-config/models', data)
|
||||
},
|
||||
|
||||
// 测试AI配置(用于新建时,需要传递完整信息)
|
||||
testAIConfig: (data: TestAIConfigRequest) => {
|
||||
return apiClient.post<TestAIConfigResponse>('/app/ai-config/test', data)
|
||||
},
|
||||
|
||||
// 通过ID测试AI配置(用于已保存的配置,后端会从数据库获取完整API Key)
|
||||
testAIConfigById: (id: number) => {
|
||||
return apiClient.post<TestAIConfigResponse>(`/app/ai-config/${id}/test`)
|
||||
},
|
||||
|
||||
// 通过ID获取模型列表(用于已保存的配置,后端会从数据库获取完整API Key)
|
||||
getModelsByConfigId: (id: number) => {
|
||||
return apiClient.get<GetModelsResponse>(`/app/ai-config/${id}/models`)
|
||||
},
|
||||
}
|
||||
92
web-app/src/api/auth.ts
Normal file
92
web-app/src/api/auth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 类型定义
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
nickName?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
nickName?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
avatar?: string
|
||||
preferences?: string
|
||||
aiSettings?: string
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
uuid: string
|
||||
username: string
|
||||
nickName: string
|
||||
email: string
|
||||
phone: string
|
||||
avatar: string
|
||||
status: string
|
||||
enable: boolean
|
||||
isAdmin: boolean
|
||||
lastLoginAt: string | null
|
||||
lastLoginIp: string
|
||||
chatCount: number
|
||||
messageCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user: User
|
||||
token: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const authApi = {
|
||||
// 用户注册
|
||||
register: (data: RegisterRequest) => {
|
||||
return apiClient.post('/app/auth/register', data)
|
||||
},
|
||||
|
||||
// 用户登录
|
||||
login: (data: LoginRequest): Promise<{ data: LoginResponse }> => {
|
||||
return apiClient.post('/app/auth/login', data)
|
||||
},
|
||||
|
||||
// 刷新 Token
|
||||
refreshToken: (refreshToken: string): Promise<{ data: LoginResponse }> => {
|
||||
return apiClient.post('/app/auth/refresh', { refreshToken })
|
||||
},
|
||||
|
||||
// 用户登出
|
||||
logout: () => {
|
||||
return apiClient.post('/app/auth/logout')
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
getUserInfo: (): Promise<{ data: User }> => {
|
||||
return apiClient.get('/app/auth/userinfo')
|
||||
},
|
||||
|
||||
// 更新用户资料
|
||||
updateProfile: (data: UpdateProfileRequest) => {
|
||||
return apiClient.put('/app/user/profile', data)
|
||||
},
|
||||
|
||||
// 修改密码
|
||||
changePassword: (data: ChangePasswordRequest) => {
|
||||
return apiClient.post('/app/user/change-password', data)
|
||||
},
|
||||
}
|
||||
130
web-app/src/api/character.ts
Normal file
130
web-app/src/api/character.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 类型定义
|
||||
export interface Character {
|
||||
id: number
|
||||
name: string
|
||||
avatar: string
|
||||
creator: string
|
||||
version: string
|
||||
description: string
|
||||
personality: string
|
||||
scenario: string
|
||||
firstMes: string
|
||||
mesExample: string
|
||||
creatorNotes: string
|
||||
systemPrompt: string
|
||||
postHistoryInstructions: string
|
||||
tags: string[]
|
||||
alternateGreetings: string[]
|
||||
characterBook: Record<string, any>
|
||||
extensions: Record<string, any>
|
||||
spec: string
|
||||
specVersion: string
|
||||
isPublic: boolean
|
||||
useCount: number
|
||||
favoriteCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCharacterRequest {
|
||||
name: string
|
||||
avatar?: string
|
||||
creator?: string
|
||||
version?: string
|
||||
description?: string
|
||||
personality?: string
|
||||
scenario?: string
|
||||
firstMes?: string
|
||||
mesExample?: string
|
||||
creatorNotes?: string
|
||||
systemPrompt?: string
|
||||
postHistoryInstructions?: string
|
||||
tags?: string[]
|
||||
alternateGreetings?: string[]
|
||||
characterBook?: Record<string, any>
|
||||
extensions?: Record<string, any>
|
||||
isPublic?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateCharacterRequest {
|
||||
name?: string
|
||||
avatar?: string
|
||||
creator?: string
|
||||
version?: string
|
||||
description?: string
|
||||
personality?: string
|
||||
scenario?: string
|
||||
firstMes?: string
|
||||
mesExample?: string
|
||||
creatorNotes?: string
|
||||
systemPrompt?: string
|
||||
postHistoryInstructions?: string
|
||||
tags?: string[]
|
||||
alternateGreetings?: string[]
|
||||
characterBook?: Record<string, any>
|
||||
extensions?: Record<string, any>
|
||||
isPublic?: boolean
|
||||
}
|
||||
|
||||
export interface GetCharacterListRequest {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
tag?: string
|
||||
isPublic?: boolean
|
||||
}
|
||||
|
||||
export interface CharacterListResponse {
|
||||
list: Character[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const characterApi = {
|
||||
// 创建角色卡
|
||||
createCharacter: (data: CreateCharacterRequest): Promise<{ data: Character }> => {
|
||||
return apiClient.post('/app/character', data)
|
||||
},
|
||||
|
||||
// 获取角色卡列表
|
||||
getCharacterList: (params?: GetCharacterListRequest): Promise<{ data: CharacterListResponse }> => {
|
||||
return apiClient.get('/app/character', { params })
|
||||
},
|
||||
|
||||
// 获取角色卡详情
|
||||
getCharacterById: (id: number): Promise<{ data: Character }> => {
|
||||
return apiClient.get(`/app/character/${id}`)
|
||||
},
|
||||
|
||||
// 更新角色卡
|
||||
updateCharacter: (id: number, data: UpdateCharacterRequest) => {
|
||||
return apiClient.put(`/app/character/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除角色卡
|
||||
deleteCharacter: (id: number) => {
|
||||
return apiClient.delete(`/app/character/${id}`)
|
||||
},
|
||||
|
||||
// 上传角色卡文件(PNG/JSON)
|
||||
uploadCharacter: (file: File): Promise<{ data: Character }> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return apiClient.post('/app/character/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// 导出角色卡为 JSON
|
||||
exportCharacter: (id: number) => {
|
||||
return apiClient.get(`/app/character/${id}/export`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
},
|
||||
}
|
||||
76
web-app/src/api/client.ts
Normal file
76
web-app/src/api/client.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import axios, {AxiosError, AxiosInstance} from 'axios'
|
||||
|
||||
// API 基础配置
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888'
|
||||
|
||||
// 创建 axios 实例
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器 - 添加 Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器 - 统一错误处理
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data
|
||||
},
|
||||
async (error: AxiosError<any>) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
// Token 过期,尝试刷新
|
||||
if (status === 401 && data?.data?.reload) {
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/app/auth/refresh`, {
|
||||
refreshToken,
|
||||
})
|
||||
const { token, refreshToken: newRefreshToken } = response.data.data
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('refreshToken', newRefreshToken)
|
||||
|
||||
// 重试原请求
|
||||
if (error.config) {
|
||||
error.config.headers.Authorization = `Bearer ${token}`
|
||||
return apiClient.request(error.config)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清除 Token 并跳转登录
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
} else {
|
||||
// 没有 refreshToken,跳转登录
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
// 返回错误信息
|
||||
return Promise.reject(data?.msg || '请求失败')
|
||||
}
|
||||
|
||||
return Promise.reject(error.message || '网络错误')
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
109
web-app/src/api/conversation.ts
Normal file
109
web-app/src/api/conversation.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 简化的角色信息(用于列表)
|
||||
export interface CharacterSimple {
|
||||
id: number
|
||||
name: string
|
||||
avatar: string
|
||||
description: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 对话列表项(轻量级)
|
||||
export interface ConversationListItem {
|
||||
id: number
|
||||
characterId: number
|
||||
title: string
|
||||
messageCount: number
|
||||
tokenCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
character?: CharacterSimple
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: number
|
||||
userId?: number
|
||||
characterId: number
|
||||
title: string
|
||||
presetId?: number
|
||||
aiProvider: string
|
||||
model: string
|
||||
settings?: Record<string, any>
|
||||
messageCount: number
|
||||
tokenCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number
|
||||
conversationId: number
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
tokenCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateConversationRequest {
|
||||
characterId: number
|
||||
title?: string
|
||||
presetId?: number
|
||||
aiProvider?: string
|
||||
model?: string
|
||||
settings?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ConversationListResponse {
|
||||
list: ConversationListItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
list: Message[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const conversationApi = {
|
||||
// 创建对话
|
||||
createConversation: (data: CreateConversationRequest) => {
|
||||
return apiClient.post<Conversation>('/app/conversation', data)
|
||||
},
|
||||
|
||||
// 获取对话列表
|
||||
getConversationList: (params: { page?: number; pageSize?: number }) => {
|
||||
return apiClient.get<ConversationListResponse>('/app/conversation', { params })
|
||||
},
|
||||
|
||||
// 获取对话详情
|
||||
getConversationById: (id: number) => {
|
||||
return apiClient.get<Conversation>(`/app/conversation/${id}`)
|
||||
},
|
||||
|
||||
// 删除对话
|
||||
deleteConversation: (id: number) => {
|
||||
return apiClient.delete(`/app/conversation/${id}`)
|
||||
},
|
||||
|
||||
// 获取消息列表
|
||||
getMessageList: (conversationId: number, params: { page?: number; pageSize?: number }) => {
|
||||
return apiClient.get<MessageListResponse>(`/app/conversation/${conversationId}/messages`, { params })
|
||||
},
|
||||
|
||||
// 发送消息
|
||||
sendMessage: (conversationId: number, data: SendMessageRequest) => {
|
||||
return apiClient.post<Message>(`/app/conversation/${conversationId}/message`, data)
|
||||
},
|
||||
|
||||
// 更新对话设置
|
||||
updateConversationSettings: (conversationId: number, settings: Record<string, any>) => {
|
||||
return apiClient.put(`/app/conversation/${conversationId}/settings`, { settings })
|
||||
},
|
||||
}
|
||||
102
web-app/src/api/preset.ts
Normal file
102
web-app/src/api/preset.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 预设接口定义
|
||||
export interface Preset {
|
||||
id: number
|
||||
userId: number
|
||||
name: string
|
||||
description: string
|
||||
isPublic: boolean
|
||||
isDefault: boolean
|
||||
temperature: number
|
||||
topP: number
|
||||
topK: number
|
||||
frequencyPenalty: number
|
||||
presencePenalty: number
|
||||
maxTokens: number
|
||||
repetitionPenalty: number
|
||||
minP: number
|
||||
topA: number
|
||||
systemPrompt: string
|
||||
stopSequences: string[]
|
||||
extensions: Record<string, any>
|
||||
useCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 创建预设请求
|
||||
export interface CreatePresetRequest {
|
||||
name: string
|
||||
description?: string
|
||||
isPublic?: boolean
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
frequencyPenalty?: number
|
||||
presencePenalty?: number
|
||||
maxTokens?: number
|
||||
repetitionPenalty?: number
|
||||
minP?: number
|
||||
topA?: number
|
||||
systemPrompt?: string
|
||||
stopSequences?: string[]
|
||||
extensions?: Record<string, any>
|
||||
}
|
||||
|
||||
// 预设列表响应
|
||||
export interface PresetListResponse {
|
||||
list: Preset[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// 预设 API
|
||||
export const presetApi = {
|
||||
// 创建预设
|
||||
createPreset: (data: CreatePresetRequest) => {
|
||||
return apiClient.post<Preset>('/app/preset', data)
|
||||
},
|
||||
|
||||
// 获取预设列表
|
||||
getPresetList: (params: { page?: number; pageSize?: number; keyword?: string; isPublic?: boolean }) => {
|
||||
return apiClient.get<PresetListResponse>('/app/preset', { params })
|
||||
},
|
||||
|
||||
// 根据ID获取预设
|
||||
getPresetById: (id: number) => {
|
||||
return apiClient.get<Preset>(`/app/preset/${id}`)
|
||||
},
|
||||
|
||||
// 更新预设
|
||||
updatePreset: (id: number, data: Partial<CreatePresetRequest>) => {
|
||||
return apiClient.put<Preset>(`/app/preset/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除预设
|
||||
deletePreset: (id: number) => {
|
||||
return apiClient.delete(`/app/preset/${id}`)
|
||||
},
|
||||
|
||||
// 设置默认预设
|
||||
setDefaultPreset: (id: number) => {
|
||||
return apiClient.post(`/app/preset/${id}/default`)
|
||||
},
|
||||
|
||||
// 导入预设
|
||||
importPreset: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return apiClient.post<Preset>('/app/preset/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
|
||||
// 导出预设
|
||||
exportPreset: (id: number) => {
|
||||
return apiClient.get(`/app/preset/${id}/export`, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
}
|
||||
18
web-app/src/api/upload.ts
Normal file
18
web-app/src/api/upload.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 上传图片接口
|
||||
export interface UploadImageResponse {
|
||||
url: string
|
||||
}
|
||||
|
||||
// 上传 API
|
||||
export const uploadApi = {
|
||||
// 上传图片到 OSS
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return apiClient.post<UploadImageResponse>('/app/upload/image', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
}
|
||||
131
web-app/src/components/CharacterPanel.tsx
Normal file
131
web-app/src/components/CharacterPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import {PanelRightClose, Settings, Sparkles} from 'lucide-react'
|
||||
import {type Character} from '../api/character'
|
||||
import {type Conversation} from '../api/conversation'
|
||||
|
||||
interface CharacterPanelProps {
|
||||
character: Character
|
||||
conversation: Conversation
|
||||
onOpenSettings: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function CharacterPanel({ character, conversation, onOpenSettings, onClose }: CharacterPanelProps) {
|
||||
const tags = Array.isArray(character.tags) ? character.tags : []
|
||||
|
||||
return (
|
||||
<div className="w-80 glass border-l border-white/10 flex flex-col">
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="font-semibold">角色信息</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
title="对话设置"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
title="隐藏角色面板"
|
||||
>
|
||||
<PanelRightClose className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="glass-hover rounded-2xl p-6 text-center">
|
||||
{character.avatar ? (
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt={character.name || '角色'}
|
||||
className="w-24 h-24 mx-auto mb-4 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 mx-auto mb-4 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-3xl">
|
||||
{character.name ? character.name.charAt(0) : '?'}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold mb-2">{character.name || '未命名角色'}</h3>
|
||||
<p className="text-sm text-white/60 mb-4 line-clamp-2">{character.description || '暂无描述'}</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-white/40">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>在线</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 text-white/80">标签</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 glass rounded-full text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.personality && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 text-white/80">性格特征</h3>
|
||||
<p className="text-sm text-white/60 leading-relaxed">{character.personality}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.scenario && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 text-white/80">场景设定</h3>
|
||||
<p className="text-sm text-white/60 leading-relaxed">{character.scenario}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.creator && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 text-white/80">创建者</h3>
|
||||
<p className="text-sm text-white/60">{character.creator}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 text-white/80">对话统计</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-white/60">消息数</span>
|
||||
<span className="font-medium">{conversation.messageCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-white/60">Token 使用</span>
|
||||
<span className="font-medium">{conversation.tokenCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-white/60">AI 模型</span>
|
||||
<span className="font-medium text-xs">{conversation.model || 'GPT-4'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{character.characterBook && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 text-white/80">世界书</h3>
|
||||
<p className="text-xs text-white/40">
|
||||
{typeof character.characterBook === 'object' && character.characterBook.entries
|
||||
? `${character.characterBook.entries.length} 个条目`
|
||||
: '已启用'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
719
web-app/src/components/ChatArea.tsx
Normal file
719
web-app/src/components/ChatArea.tsx
Normal file
@@ -0,0 +1,719 @@
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
Mic,
|
||||
MoreVertical,
|
||||
Paperclip,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Settings,
|
||||
Trash2,
|
||||
Waves,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import {type Conversation, conversationApi, type Message} from '../api/conversation'
|
||||
import {type Character} from '../api/character'
|
||||
import {type AIConfig, aiConfigApi} from '../api/aiConfig'
|
||||
import {type Preset, presetApi} from '../api/preset'
|
||||
import MessageContent from './MessageContent'
|
||||
|
||||
interface ChatAreaProps {
|
||||
conversation: Conversation
|
||||
character: Character
|
||||
onConversationUpdate: (conversation: Conversation) => void
|
||||
}
|
||||
|
||||
export default function ChatArea({ conversation, character, onConversationUpdate }: ChatAreaProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null)
|
||||
const [aiConfigs, setAiConfigs] = useState<AIConfig[]>([])
|
||||
const [selectedConfigId, setSelectedConfigId] = useState<number>()
|
||||
const [showModelSelector, setShowModelSelector] = useState(false)
|
||||
const [streamEnabled, setStreamEnabled] = useState(true) // 默认启用流式传输
|
||||
const [presets, setPresets] = useState<Preset[]>([])
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<number>()
|
||||
const [showPresetSelector, setShowPresetSelector] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const modelSelectorRef = useRef<HTMLDivElement>(null)
|
||||
const presetSelectorRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
// 检查是否点击在模型选择器外部
|
||||
if (showModelSelector && modelSelectorRef.current && !modelSelectorRef.current.contains(target)) {
|
||||
setShowModelSelector(false)
|
||||
}
|
||||
|
||||
// 检查是否点击在预设选择器外部
|
||||
if (showPresetSelector && presetSelectorRef.current && !presetSelectorRef.current.contains(target)) {
|
||||
setShowPresetSelector(false)
|
||||
}
|
||||
|
||||
// 检查是否点击在菜单外部
|
||||
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
|
||||
setShowMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showModelSelector, showPresetSelector, showMenu])
|
||||
|
||||
useEffect(() => {
|
||||
loadMessages()
|
||||
loadAIConfigs()
|
||||
loadCurrentConfig()
|
||||
loadPresets()
|
||||
loadCurrentPreset()
|
||||
}, [conversation.id])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await conversationApi.getMessageList(conversation.id, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
})
|
||||
setMessages(response.data.list || [])
|
||||
} catch (err) {
|
||||
console.error('加载消息失败:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAIConfigs = async () => {
|
||||
try {
|
||||
const response = await aiConfigApi.getAIConfigList()
|
||||
const activeConfigs = response.data.list.filter(config => config.isActive)
|
||||
setAiConfigs(activeConfigs)
|
||||
} catch (err) {
|
||||
console.error('加载 AI 配置失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCurrentConfig = () => {
|
||||
if (conversation.settings) {
|
||||
try {
|
||||
const settings = typeof conversation.settings === 'string'
|
||||
? JSON.parse(conversation.settings)
|
||||
: conversation.settings
|
||||
if (settings.aiConfigId) {
|
||||
setSelectedConfigId(settings.aiConfigId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析设置失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
const response = await presetApi.getPresetList({ page: 1, pageSize: 100 })
|
||||
setPresets(response.data.list)
|
||||
} catch (err) {
|
||||
console.error('加载预设失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCurrentPreset = () => {
|
||||
if (conversation.settings) {
|
||||
try {
|
||||
const settings = typeof conversation.settings === 'string'
|
||||
? JSON.parse(conversation.settings)
|
||||
: conversation.settings
|
||||
if (settings.presetId) {
|
||||
setSelectedPresetId(settings.presetId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析设置失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePresetChange = async (presetId: number) => {
|
||||
try {
|
||||
const settings = conversation.settings
|
||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
|
||||
: {}
|
||||
|
||||
settings.presetId = presetId
|
||||
|
||||
await conversationApi.updateConversationSettings(conversation.id, settings)
|
||||
setSelectedPresetId(presetId)
|
||||
setShowPresetSelector(false)
|
||||
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} catch (err) {
|
||||
console.error('更新预设失败:', err)
|
||||
alert('更新失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelChange = async (configId: number) => {
|
||||
try {
|
||||
const settings = conversation.settings
|
||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
|
||||
: {}
|
||||
|
||||
settings.aiConfigId = configId
|
||||
|
||||
await conversationApi.updateConversationSettings(conversation.id, settings)
|
||||
setSelectedConfigId(configId)
|
||||
setShowModelSelector(false)
|
||||
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} catch (err) {
|
||||
console.error('更新模型配置失败:', err)
|
||||
alert('更新失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
// 防止重复发送
|
||||
if (!inputValue.trim() || sending) return
|
||||
|
||||
const userMessage = inputValue.trim()
|
||||
|
||||
// 立即清空输入框和设置发送状态,防止重复触发
|
||||
setInputValue('')
|
||||
setSending(true)
|
||||
|
||||
// 立即显示用户消息
|
||||
const tempUserMessage: Message = {
|
||||
id: Date.now(),
|
||||
conversationId: conversation.id,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
tokenCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, tempUserMessage])
|
||||
|
||||
// 创建临时AI消息用于流式显示
|
||||
const tempAIMessage: Message = {
|
||||
id: Date.now() + 1,
|
||||
conversationId: conversation.id,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tokenCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
if (streamEnabled) {
|
||||
// 流式传输
|
||||
console.log('[Stream] 开始流式传输...')
|
||||
setMessages((prev) => [...prev, tempAIMessage])
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify({ content: userMessage }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('流式传输失败')
|
||||
}
|
||||
|
||||
console.log('[Stream] 连接成功,开始接收数据...')
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
if (reader) {
|
||||
let fullContent = ''
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
console.log('[Stream] 传输完成')
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
|
||||
// 保留最后一行(可能不完整)
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
let currentEvent = ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim()
|
||||
console.log('[Stream] 事件类型:', currentEvent)
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
|
||||
if (currentEvent === 'message') {
|
||||
// 消息内容 - 后端现在发送的是纯文本,不再是JSON
|
||||
fullContent += data
|
||||
console.log('[Stream] 接收内容片段:', data)
|
||||
// 实时更新临时AI消息的内容
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === tempAIMessage.id ? { ...m, content: fullContent } : m
|
||||
)
|
||||
)
|
||||
} else if (currentEvent === 'done') {
|
||||
// 流式传输完成
|
||||
console.log('[Stream] 收到完成信号,重新加载消息')
|
||||
await loadMessages()
|
||||
break
|
||||
} else if (currentEvent === 'error') {
|
||||
// 错误处理
|
||||
console.error('[Stream] 错误:', data)
|
||||
throw new Error(data)
|
||||
}
|
||||
currentEvent = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新对话信息
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} else {
|
||||
// 普通传输
|
||||
const response = await conversationApi.sendMessage(conversation.id, {
|
||||
content: userMessage,
|
||||
})
|
||||
|
||||
// 更新消息列表(包含AI回复)
|
||||
await loadMessages()
|
||||
|
||||
// 更新对话信息
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('发送消息失败:', err)
|
||||
alert(err.response?.data?.msg || '发送消息失败,请重试')
|
||||
// 移除临时消息
|
||||
setMessages((prev) => prev.filter((m) => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !sending && inputValue.trim()) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyMessage = (content: string, id: number) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
const handleRegenerateResponse = async () => {
|
||||
if (messages.length === 0 || sending) return
|
||||
|
||||
// 找到最后一条用户消息
|
||||
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user')
|
||||
if (!lastUserMessage) return
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
await conversationApi.sendMessage(conversation.id, {
|
||||
content: lastUserMessage.content,
|
||||
})
|
||||
await loadMessages()
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} catch (err) {
|
||||
console.error('重新生成失败:', err)
|
||||
alert('重新生成失败,请重试')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteConversation = async () => {
|
||||
if (!confirm('确定要删除这个对话吗?')) return
|
||||
|
||||
try {
|
||||
await conversationApi.deleteConversation(conversation.id)
|
||||
window.location.href = '/chat'
|
||||
} catch (err) {
|
||||
console.error('删除对话失败:', err)
|
||||
alert('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportConversation = () => {
|
||||
const content = messages
|
||||
.map((msg) => `[${msg.role}] ${msg.content}`)
|
||||
.join('\n\n')
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${conversation.title}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="p-4 glass border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">{conversation.title}</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-white/60">
|
||||
<span>与 {character.name} 对话中</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative" ref={modelSelectorRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowModelSelector(!showModelSelector)
|
||||
setShowPresetSelector(false)
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
|
||||
title="切换模型"
|
||||
>
|
||||
<Zap className="w-4 h-4 text-secondary" />
|
||||
<span className="text-xs">
|
||||
{selectedConfigId
|
||||
? aiConfigs.find(c => c.id === selectedConfigId)?.name || '默认模型'
|
||||
: '默认模型'}
|
||||
</span>
|
||||
</button>
|
||||
{showModelSelector && createPortal(
|
||||
<div
|
||||
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
|
||||
style={{
|
||||
top: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
|
||||
left: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().right - 200 : 0,
|
||||
zIndex: 9999
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{aiConfigs.length === 0 ? (
|
||||
<div className="px-4 py-3 text-xs text-white/60 text-center">
|
||||
暂无可用配置
|
||||
</div>
|
||||
) : (
|
||||
aiConfigs.map((config) => (
|
||||
<button
|
||||
key={config.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleModelChange(config.id)
|
||||
}}
|
||||
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
|
||||
selectedConfigId === config.id ? 'bg-primary/20 ring-1 ring-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{config.name}</div>
|
||||
<div className="text-xs text-white/60 mt-0.5">
|
||||
{config.provider} • {config.defaultModel}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预设选择器 */}
|
||||
<div className="relative" ref={presetSelectorRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowPresetSelector(!showPresetSelector)
|
||||
setShowModelSelector(false)
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
|
||||
title="选择预设"
|
||||
>
|
||||
<Settings className="w-4 h-4 text-primary" />
|
||||
<span className="text-xs">
|
||||
{selectedPresetId
|
||||
? presets.find(p => p.id === selectedPresetId)?.name || '默认预设'
|
||||
: '默认预设'}
|
||||
</span>
|
||||
</button>
|
||||
{showPresetSelector && createPortal(
|
||||
<div
|
||||
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
|
||||
style={{
|
||||
top: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
|
||||
left: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().right - 200 : 0,
|
||||
zIndex: 9999
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{presets.length === 0 ? (
|
||||
<div className="px-4 py-3 text-xs text-white/60 text-center">
|
||||
暂无可用预设
|
||||
</div>
|
||||
) : (
|
||||
presets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePresetChange(preset.id)
|
||||
}}
|
||||
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
|
||||
selectedPresetId === preset.id ? 'bg-primary/20 ring-1 ring-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{preset.name}</div>
|
||||
<div className="text-xs text-white/60 mt-0.5">
|
||||
{preset.description || `温度: ${preset.temperature}`}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setStreamEnabled(!streamEnabled)}
|
||||
className={`p-2 glass-hover rounded-lg cursor-pointer ${
|
||||
streamEnabled ? 'text-green-400' : 'text-white/60'
|
||||
}`}
|
||||
title={streamEnabled ? '流式传输已启用' : '流式传输已禁用'}
|
||||
>
|
||||
<Waves className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRegenerateResponse}
|
||||
disabled={sending || messages.length === 0}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="重新生成回复"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowMenu(!showMenu)
|
||||
setShowModelSelector(false)
|
||||
setShowPresetSelector(false)
|
||||
}}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</button>
|
||||
{showMenu && createPortal(
|
||||
<div
|
||||
className="fixed glass rounded-xl p-2 min-w-[160px] shadow-xl"
|
||||
style={{
|
||||
top: menuRef.current ? menuRef.current.getBoundingClientRect().bottom + 8 : 0,
|
||||
left: menuRef.current ? menuRef.current.getBoundingClientRect().right - 160 : 0,
|
||||
zIndex: 9999
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={handleExportConversation}
|
||||
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
导出对话
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteConversation}
|
||||
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2 text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
删除对话
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{loading ? (
|
||||
<div className="text-center text-white/60">加载消息中...</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-white/60 py-12">
|
||||
<p className="mb-2">还没有消息</p>
|
||||
<p className="text-sm">发送第一条消息开始对话吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-2xl min-w-0 relative ${
|
||||
msg.role === 'user'
|
||||
? 'glass-hover rounded-2xl rounded-br-md p-4'
|
||||
: 'glass-hover rounded-2xl rounded-bl-md p-4'
|
||||
}`}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{character.avatar && (
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt={character.name}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-primary">{character.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
content={msg.content}
|
||||
role={msg.role}
|
||||
onChoiceSelect={(choice) => {
|
||||
setInputValue(choice)
|
||||
// 自动聚焦到输入框
|
||||
textareaRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-white/40">{formatTime(msg.createdAt)}</span>
|
||||
<button
|
||||
onClick={() => handleCopyMessage(msg.content, msg.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 glass-hover rounded transition-opacity cursor-pointer"
|
||||
title="复制消息"
|
||||
>
|
||||
{copiedId === msg.id ? (
|
||||
<Check className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{sending && (
|
||||
<div className="flex justify-start">
|
||||
<div className="glass-hover rounded-2xl rounded-bl-md p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 glass border-t border-white/10">
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
|
||||
title="附件(开发中)"
|
||||
disabled
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 glass rounded-2xl p-3 focus-within:ring-2 focus-within:ring-primary/50 transition-all">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
// 自动调整高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={sending ? '正在发送...' : '输入消息... (Enter发送,Shift+Enter换行)'}
|
||||
rows={1}
|
||||
className="w-full bg-transparent resize-none focus:outline-none text-sm"
|
||||
style={{ maxHeight: '120px', minHeight: '24px' }}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
|
||||
title="语音输入(开发中)"
|
||||
disabled
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || sending}
|
||||
className="p-3 bg-gradient-to-r from-primary to-secondary rounded-lg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="发送消息"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-white/40">
|
||||
<span>消息: {conversation.messageCount} | Token: {conversation.tokenCount}</span>
|
||||
{sending && <span className="text-primary animate-pulse">AI 正在思考...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
341
web-app/src/components/MessageContent.tsx
Normal file
341
web-app/src/components/MessageContent.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {Code, Eye, Play} from 'lucide-react'
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string
|
||||
role: 'user' | 'assistant'
|
||||
onChoiceSelect?: (choice: string) => void
|
||||
}
|
||||
|
||||
interface Choice {
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
// 解析并高亮对白的组件
|
||||
function DialogueText({ text }: { text: string }) {
|
||||
// 匹配各种引号格式的对白
|
||||
const dialogueRegex = /([""「『])(.*?)([""」』])|(")(.*?)(")|(')(.*?)(')/g
|
||||
|
||||
const parts: JSX.Element[] = []
|
||||
let lastIndex = 0
|
||||
let match
|
||||
let key = 0
|
||||
|
||||
while ((match = dialogueRegex.exec(text)) !== null) {
|
||||
// 添加对白之前的文本
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(
|
||||
<span key={`text-${key++}`} className="text-white/80">
|
||||
{text.substring(lastIndex, match.index)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 提取对白内容(处理不同的引号组)
|
||||
const dialogue = match[2] || match[5] || match[8]
|
||||
const openQuote = match[1] || match[4] || match[7]
|
||||
const closeQuote = match[3] || match[6] || match[9]
|
||||
|
||||
// 添加高亮的对白
|
||||
parts.push(
|
||||
<span key={`dialogue-${key++}`} className="inline-block">
|
||||
<span className="text-primary/60">{openQuote}</span>
|
||||
<span className="text-primary font-medium px-0.5">{dialogue}</span>
|
||||
<span className="text-primary/60">{closeQuote}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
|
||||
// 添加剩余的文本
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(
|
||||
<span key={`text-${key++}`} className="text-white/80">
|
||||
{text.substring(lastIndex)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{parts.length > 0 ? parts : <span className="text-white/80">{text}</span>}</>
|
||||
}
|
||||
|
||||
// 解析选择项的函数
|
||||
function parseChoices(content: string): { choices: Choice[]; cleanContent: string } {
|
||||
// 匹配 <choice>...</choice> 或 [choice]...[/choice] 格式
|
||||
const choiceRegex = /(?:<choice>|\\[choice\\])([\s\S]*?)(?:<\/choice>|\\[\/choice\\])/i
|
||||
const match = content.match(choiceRegex)
|
||||
|
||||
if (!match) {
|
||||
// 如果没有标准格式,尝试从HTML中提取选择项
|
||||
// 匹配类似 "1.xxx" 或 "A.xxx" 的列表项
|
||||
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、::]\s*([^<]+)/gi
|
||||
const choices: Choice[] = []
|
||||
let htmlMatch
|
||||
|
||||
while ((htmlMatch = htmlChoiceRegex.exec(content)) !== null) {
|
||||
choices.push({
|
||||
label: htmlMatch[1],
|
||||
text: htmlMatch[2].trim()
|
||||
})
|
||||
}
|
||||
|
||||
if (choices.length > 0) {
|
||||
return { choices, cleanContent: content }
|
||||
}
|
||||
|
||||
return { choices: [], cleanContent: content }
|
||||
}
|
||||
|
||||
const choiceBlock = match[1]
|
||||
const choices: Choice[] = []
|
||||
|
||||
// 匹配 A. text, B. text 等格式
|
||||
const optionRegex = /^([A-Z])[.、::]\s*(.+)$/gm
|
||||
let optionMatch
|
||||
|
||||
while ((optionMatch = optionRegex.exec(choiceBlock)) !== null) {
|
||||
choices.push({
|
||||
label: optionMatch[1],
|
||||
text: optionMatch[2].trim()
|
||||
})
|
||||
}
|
||||
|
||||
// 移除选择块,返回清理后的内容
|
||||
const cleanContent = content.replace(choiceRegex, '').trim()
|
||||
|
||||
return { choices, cleanContent }
|
||||
}
|
||||
|
||||
// 清理脚本输出内容
|
||||
function cleanScriptOutput(content: string): string {
|
||||
// 移除 <UpdateVariable>...</UpdateVariable> 块
|
||||
let cleaned = content.replace(/<UpdateVariable>[\s\S]*?<\/UpdateVariable>/gi, '')
|
||||
|
||||
// 移除 <Analysis>...</Analysis> 块
|
||||
cleaned = cleaned.replace(/<Analysis>[\s\S]*?<\/Analysis>/gi, '')
|
||||
|
||||
// 移除 _.set() 调用
|
||||
cleaned = cleaned.replace(/^\s*_.set\([^)]+\);\s*$/gm, '')
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
// 解析状态面板数据
|
||||
function parseStatusPanel(content: string): { status: any; cleanContent: string } {
|
||||
const statusRegex = /<status_current_variable>([\s\S]*?)<\/status_current_variable>/i
|
||||
const match = content.match(statusRegex)
|
||||
|
||||
if (!match) {
|
||||
return { status: null, cleanContent: content }
|
||||
}
|
||||
|
||||
try {
|
||||
const statusData = JSON.parse(match[1].trim())
|
||||
const cleanContent = content.replace(statusRegex, '').trim()
|
||||
return { status: statusData, cleanContent }
|
||||
} catch (e) {
|
||||
console.error('解析状态面板失败:', e)
|
||||
return { status: null, cleanContent: content }
|
||||
}
|
||||
}
|
||||
|
||||
export default function MessageContent({ content, role, onChoiceSelect }: MessageContentProps) {
|
||||
const [showRaw, setShowRaw] = useState(false)
|
||||
const [hasHtml, setHasHtml] = useState(false)
|
||||
const [hasScript, setHasScript] = useState(false)
|
||||
const [allowScript, setAllowScript] = useState(true) // 默认启用脚本
|
||||
const [choices, setChoices] = useState<Choice[]>([])
|
||||
const [displayContent, setDisplayContent] = useState(content)
|
||||
const [statusPanel, setStatusPanel] = useState<any>(null)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[MessageContent] 原始内容:', content)
|
||||
|
||||
// 解析状态面板
|
||||
const { status, cleanContent: contentAfterStatus } = parseStatusPanel(content)
|
||||
console.log('[MessageContent] 状态面板:', status)
|
||||
setStatusPanel(status)
|
||||
|
||||
// 解析选择项
|
||||
const { choices: parsedChoices, cleanContent } = parseChoices(contentAfterStatus)
|
||||
console.log('[MessageContent] 选择项:', parsedChoices)
|
||||
setChoices(parsedChoices)
|
||||
|
||||
// 清理脚本输出
|
||||
const finalContent = cleanScriptOutput(cleanContent)
|
||||
console.log('[MessageContent] 清理后内容:', finalContent)
|
||||
setDisplayContent(finalContent)
|
||||
|
||||
// 检测内容类型
|
||||
const htmlRegex = /<[^>]+>/g
|
||||
const scriptRegex = /<script[\s\S]*?<\/script>/gi
|
||||
|
||||
const hasHtmlContent = htmlRegex.test(finalContent)
|
||||
const hasScriptContent = scriptRegex.test(finalContent)
|
||||
|
||||
console.log('[MessageContent] hasHtml:', hasHtmlContent, 'hasScript:', hasScriptContent)
|
||||
|
||||
setHasHtml(hasHtmlContent)
|
||||
setHasScript(hasScriptContent)
|
||||
}, [content])
|
||||
|
||||
const renderInIframe = () => {
|
||||
if (!iframeRef.current) return
|
||||
|
||||
const iframe = iframeRef.current
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document
|
||||
|
||||
if (doc) {
|
||||
doc.open()
|
||||
doc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${displayContent}
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
doc.close()
|
||||
|
||||
// 自动调整iframe高度
|
||||
setTimeout(() => {
|
||||
if (doc.body) {
|
||||
const height = doc.body.scrollHeight
|
||||
iframe.style.height = `${Math.max(height, 100)}px`
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (allowScript && hasScript) {
|
||||
renderInIframe()
|
||||
}
|
||||
}, [allowScript, displayContent])
|
||||
|
||||
// 如果是用户消息,直接显示纯文本
|
||||
if (role === 'user') {
|
||||
return <p className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">{content}</p>
|
||||
}
|
||||
|
||||
// AI消息 - 支持多种渲染模式
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 控制按钮 */}
|
||||
{(hasHtml || hasScript) && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs glass-hover rounded cursor-pointer"
|
||||
title={showRaw ? '显示渲染' : '显示源码'}
|
||||
>
|
||||
{showRaw ? <Eye className="w-3 h-3" /> : <Code className="w-3 h-3" />}
|
||||
{showRaw ? '渲染' : '源码'}
|
||||
</button>
|
||||
|
||||
{hasScript && (
|
||||
<button
|
||||
onClick={() => setAllowScript(!allowScript)}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer ${
|
||||
allowScript ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}
|
||||
title={allowScript ? '禁用脚本' : '允许脚本'}
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
{allowScript ? '脚本已启用' : '启用脚本'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容渲染 */}
|
||||
{showRaw ? (
|
||||
<pre className="text-xs bg-black/30 p-3 rounded-lg overflow-x-auto">
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
) : hasScript && allowScript ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="w-full border border-white/10 rounded-lg bg-black/20"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
style={{ minHeight: '100px' }}
|
||||
/>
|
||||
) : hasHtml ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere"
|
||||
dangerouslySetInnerHTML={{ __html: displayContent }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||
<DialogueText text={displayContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sudachi 选择按钮 */}
|
||||
{choices.length > 0 && onChoiceSelect && (
|
||||
<div className="mt-4 p-4 glass rounded-lg border border-primary/20">
|
||||
<div className="text-sm font-medium text-primary mb-3">请选择以下选项之一</div>
|
||||
<div className="space-y-2">
|
||||
{choices.map((choice) => (
|
||||
<button
|
||||
key={choice.label}
|
||||
onClick={() => onChoiceSelect(choice.text)}
|
||||
className="w-full text-left px-4 py-3 glass-hover rounded-lg border border-white/10 hover:border-primary/50 transition-all group"
|
||||
>
|
||||
<span className="text-primary font-bold mr-2">{choice.label}.</span>
|
||||
<span className="text-white/80 group-hover:text-white">{choice.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态面板 */}
|
||||
{statusPanel && (
|
||||
<div className="mt-4 p-4 glass rounded-lg border border-secondary/20">
|
||||
<div className="text-sm font-medium text-secondary mb-3">状态面板</div>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(statusPanel).map(([category, data]: [string, any]) => (
|
||||
<div key={category} className="space-y-1">
|
||||
<div className="text-xs font-semibold text-secondary/80">{category}</div>
|
||||
<div className="pl-3 space-y-1">
|
||||
{typeof data === 'object' && data !== null ? (
|
||||
Object.entries(data).map(([key, value]: [string, any]) => (
|
||||
<div key={key} className="flex justify-between text-xs">
|
||||
<span className="text-white/60">{key}:</span>
|
||||
<span className="text-white/90 font-medium">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-white/90">{String(data)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
web-app/src/components/Navbar.tsx
Normal file
138
web-app/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {LogOut, Menu, Shield, Sparkles, User} from 'lucide-react'
|
||||
import {authApi, type User as UserType} from '../api/auth'
|
||||
|
||||
export default function Navbar() {
|
||||
const [user, setUser] = useState<UserType | null>(null)
|
||||
const [showUserMenu, setShowUserMenu] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const loadUser = async () => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const response = await authApi.getUserInfo()
|
||||
setUser(response.data)
|
||||
} catch (err) {
|
||||
console.error('获取用户信息失败:', err)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
setUser(null)
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="fixed top-4 left-4 right-4 z-50">
|
||||
<div className="max-w-7xl mx-auto glass rounded-2xl px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
云酒馆
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<Link to="/" className="text-sm hover:text-primary transition-colors cursor-pointer">
|
||||
首页
|
||||
</Link>
|
||||
<Link to="/market" className="text-sm hover:text-primary transition-colors cursor-pointer">
|
||||
角色广场
|
||||
</Link>
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/characters" className="text-sm hover:text-primary transition-colors cursor-pointer">
|
||||
角色管理
|
||||
</Link>
|
||||
<Link to="/presets" className="text-sm hover:text-primary transition-colors cursor-pointer">
|
||||
预设管理
|
||||
</Link>
|
||||
<Link to="/chat" className="text-sm hover:text-primary transition-colors cursor-pointer">
|
||||
我的对话
|
||||
</Link>
|
||||
{user.isAdmin && (
|
||||
<Link to="/admin" className="text-sm hover:text-primary transition-colors cursor-pointer flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
管理后台
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
|
||||
>
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.nickName} className="w-6 h-6 rounded-full" />
|
||||
) : (
|
||||
<User className="w-5 h-5" />
|
||||
)}
|
||||
<span>{user.nickName || user.username}</span>
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 glass rounded-xl overflow-hidden">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block px-4 py-3 text-sm hover:bg-white/5 cursor-pointer"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
个人中心
|
||||
</Link>
|
||||
{user.isAdmin && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="block px-4 py-3 text-sm hover:bg-white/5 cursor-pointer flex items-center gap-2"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
管理后台
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-4 py-3 text-sm hover:bg-white/5 cursor-pointer flex items-center gap-2 text-red-400"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="hidden md:block px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer">
|
||||
登录
|
||||
</Link>
|
||||
<Link to="/register" className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm hover:opacity-90 transition-opacity cursor-pointer">
|
||||
开始使用
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<button className="md:hidden p-2 glass-hover rounded-lg cursor-pointer">
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
247
web-app/src/components/SettingsPanel.tsx
Normal file
247
web-app/src/components/SettingsPanel.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import {Image, Palette, RotateCcw, Save, Upload, X} from 'lucide-react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {type Conversation, conversationApi} from '../api/conversation'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
conversation: Conversation
|
||||
onClose: () => void
|
||||
onUpdate?: (settings: ConversationSettings) => void
|
||||
}
|
||||
|
||||
interface ConversationSettings {
|
||||
themeColor: string
|
||||
backgroundImage?: string
|
||||
}
|
||||
|
||||
const THEME_COLORS = [
|
||||
{ value: '#7C3AED', label: '紫色' },
|
||||
{ value: '#F97316', label: '橙色' },
|
||||
{ value: '#10B981', label: '绿色' },
|
||||
{ value: '#3B82F6', label: '蓝色' },
|
||||
{ value: '#EC4899', label: '粉色' },
|
||||
{ value: '#F59E0B', label: '黄色' },
|
||||
]
|
||||
|
||||
export default function SettingsPanel({ conversation, onClose, onUpdate }: SettingsPanelProps) {
|
||||
const [settings, setSettings] = useState<ConversationSettings>({
|
||||
themeColor: '#7C3AED',
|
||||
})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [backgroundPreview, setBackgroundPreview] = useState<string>()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
const loadSettings = () => {
|
||||
if (conversation.settings) {
|
||||
try {
|
||||
const parsed = typeof conversation.settings === 'string'
|
||||
? JSON.parse(conversation.settings)
|
||||
: conversation.settings
|
||||
|
||||
// 合并默认设置和保存的设置
|
||||
const mergedSettings = {
|
||||
themeColor: '#7C3AED',
|
||||
...parsed
|
||||
}
|
||||
|
||||
setSettings(mergedSettings)
|
||||
|
||||
if (parsed.backgroundImage) {
|
||||
setBackgroundPreview(parsed.backgroundImage)
|
||||
}
|
||||
|
||||
// 应用主题色
|
||||
if (parsed.themeColor) {
|
||||
document.documentElement.style.setProperty('--color-primary', parsed.themeColor)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析设置失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (key: keyof ConversationSettings, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }))
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('图片大小不能超过 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const dataUrl = event.target?.result as string
|
||||
setBackgroundPreview(dataUrl)
|
||||
handleChange('backgroundImage', dataUrl)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleRemoveBackground = () => {
|
||||
setBackgroundPreview(undefined)
|
||||
handleChange('backgroundImage', undefined)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 保存到后端
|
||||
await conversationApi.updateConversationSettings(conversation.id, settings)
|
||||
|
||||
// 应用主题色到根元素
|
||||
if (settings.themeColor) {
|
||||
document.documentElement.style.setProperty('--color-primary', settings.themeColor)
|
||||
}
|
||||
|
||||
// 通知父组件更新
|
||||
onUpdate?.(settings)
|
||||
setHasChanges(false)
|
||||
alert('设置已保存')
|
||||
} catch (err) {
|
||||
console.error('保存设置失败:', err)
|
||||
alert('保存失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setSettings({
|
||||
themeColor: '#7C3AED',
|
||||
})
|
||||
setBackgroundPreview(undefined)
|
||||
setHasChanges(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="p-4 glass border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">对话设置</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="glass-hover rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-primary/20 rounded-lg">
|
||||
<Palette className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold">外观设置</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-3 block">主题颜色</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{THEME_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => handleChange('themeColor', color.value)}
|
||||
className={`p-4 rounded-xl cursor-pointer transition-all ${
|
||||
settings.themeColor === color.value
|
||||
? 'ring-2 ring-white/40 scale-105'
|
||||
: 'ring-1 ring-white/20 hover:ring-white/30'
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
>
|
||||
<div className="text-xs font-medium text-white">{color.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-3 block">对话背景图</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{backgroundPreview ? (
|
||||
<div className="relative glass rounded-xl p-4">
|
||||
<img
|
||||
src={backgroundPreview}
|
||||
alt="背景预览"
|
||||
className="w-full h-32 object-cover rounded-lg"
|
||||
/>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
更换图片
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemoveBackground}
|
||||
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer text-red-400"
|
||||
>
|
||||
移除背景
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full p-8 glass-hover rounded-xl cursor-pointer border-2 border-dashed border-white/20 hover:border-primary/50 transition-all"
|
||||
>
|
||||
<Image className="w-8 h-8 mx-auto mb-2 text-white/40" />
|
||||
<p className="text-sm text-white/60">点击上传背景图片</p>
|
||||
<p className="text-xs text-white/40 mt-1">支持 JPG、PNG,最大 5MB</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-hover rounded-2xl p-4">
|
||||
<div className="text-xs text-white/60 space-y-2">
|
||||
<p>💡 主题颜色会应用到整个界面</p>
|
||||
<p>💡 背景图片仅在对话页面显示</p>
|
||||
<p>💡 AI 模型可在对话界面顶部切换</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 glass border-t border-white/10 flex gap-3">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges}
|
||||
className="flex-1 px-4 py-3 glass-hover rounded-xl text-sm font-medium cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl text-sm font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
249
web-app/src/components/Sidebar.tsx
Normal file
249
web-app/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import {ArrowLeft, MessageSquare, PanelLeftClose, Plus, Search, X} from 'lucide-react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {type Conversation, conversationApi, type ConversationListItem} from '../api/conversation'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
|
||||
interface SidebarProps {
|
||||
currentConversationId?: number
|
||||
onConversationChange: (conversation: Conversation) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function Sidebar({ currentConversationId, onConversationChange, onClose }: SidebarProps) {
|
||||
const [conversations, setConversations] = useState<ConversationListItem[]>([])
|
||||
const [characters, setCharacters] = useState<Character[]>([])
|
||||
const [showCharacterModal, setShowCharacterModal] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations()
|
||||
loadCharacters()
|
||||
}, [])
|
||||
|
||||
const loadConversations = async () => {
|
||||
try {
|
||||
const response = await conversationApi.getConversationList({ page: 1, pageSize: 50 })
|
||||
setConversations(response.data.list || [])
|
||||
} catch (err) {
|
||||
console.error('加载对话列表失败:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCharacters = async () => {
|
||||
try {
|
||||
const response = await characterApi.getCharacterList({ page: 1, pageSize: 50 })
|
||||
setCharacters(response.data.list || [])
|
||||
} catch (err) {
|
||||
console.error('加载角色列表失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredCharacters = characters.filter(char =>
|
||||
char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
char.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const handleStartChat = async (character: Character) => {
|
||||
try {
|
||||
const response = await conversationApi.createConversation({
|
||||
characterId: character.id,
|
||||
title: `与 ${character.name} 的对话`,
|
||||
})
|
||||
setShowCharacterModal(false)
|
||||
onConversationChange(response.data)
|
||||
await loadConversations()
|
||||
} catch (err) {
|
||||
console.error('创建对话失败:', err)
|
||||
alert('创建对话失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConversationClick = async (conv: ConversationListItem) => {
|
||||
try {
|
||||
// 获取完整的对话信息
|
||||
const response = await conversationApi.getConversationById(conv.id)
|
||||
onConversationChange(response.data)
|
||||
} catch (err) {
|
||||
console.error('加载对话失败:', err)
|
||||
alert('加载对话失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-80 glass border-r border-white/10 flex flex-col">
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link to="/" className="p-2 glass-hover rounded-lg cursor-pointer group" title="返回首页">
|
||||
<ArrowLeft className="w-5 h-5 group-hover:text-primary transition-colors" />
|
||||
</Link>
|
||||
<h1 className="flex-1 text-xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
对话
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setShowCharacterModal(true)}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
title="新建对话"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
title="隐藏侧边栏"
|
||||
>
|
||||
<PanelLeftClose className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索对话..."
|
||||
className="w-full pl-10 pr-4 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-white/60">加载中...</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="text-center py-8 text-white/60">
|
||||
<p className="mb-2">还没有对话</p>
|
||||
<button
|
||||
onClick={() => setShowCharacterModal(true)}
|
||||
className="text-sm text-primary hover:text-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
创建第一个对话
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => handleConversationClick(conv)}
|
||||
className={`w-full p-3 glass-hover rounded-lg mb-2 text-left cursor-pointer group ${
|
||||
currentConversationId === conv.id ? 'ring-2 ring-primary/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/20 rounded-lg group-hover:bg-primary/30 transition-colors">
|
||||
<MessageSquare className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="font-medium text-sm truncate">{conv.title}</h3>
|
||||
<span className="text-xs text-white/40">{formatTime(conv.updatedAt)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/60">消息: {conv.messageCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCharacterModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-3xl p-6 max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">选择角色开始对话</h2>
|
||||
<button
|
||||
onClick={() => setShowCharacterModal(false)}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索角色..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2">
|
||||
{filteredCharacters.length === 0 ? (
|
||||
<div className="text-center py-12 text-white/60">
|
||||
<p className="mb-4">没有找到角色</p>
|
||||
<Link
|
||||
to="/characters"
|
||||
onClick={() => setShowCharacterModal(false)}
|
||||
className="text-primary hover:text-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
去创建角色卡 →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{filteredCharacters.map((char) => (
|
||||
<button
|
||||
key={char.id}
|
||||
onClick={() => handleStartChat(char)}
|
||||
className="glass-hover rounded-2xl p-4 text-left cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{char.avatar && (
|
||||
<img
|
||||
src={char.avatar}
|
||||
alt={char.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold mb-1 group-hover:text-primary transition-colors">
|
||||
{char.name}
|
||||
</h3>
|
||||
<p className="text-xs text-white/40 line-clamp-2">{char.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<Link
|
||||
to="/characters"
|
||||
onClick={() => setShowCharacterModal(false)}
|
||||
className="text-sm text-primary hover:text-secondary transition-colors cursor-pointer"
|
||||
>
|
||||
管理我的角色卡 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
27
web-app/src/index.css
Normal file
27
web-app/src/index.css
Normal file
@@ -0,0 +1,27 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-primary: #7C3AED;
|
||||
--color-secondary: #A78BFA;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-[#0A0A0F] text-white font-sans antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass {
|
||||
@apply bg-white/5 backdrop-blur-xl border border-white/10;
|
||||
}
|
||||
|
||||
.glass-hover {
|
||||
@apply transition-all duration-200 hover:bg-white/10 hover:border-white/20;
|
||||
}
|
||||
}
|
||||
10
web-app/src/main.tsx
Normal file
10
web-app/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
1102
web-app/src/pages/AdminPage.tsx
Normal file
1102
web-app/src/pages/AdminPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
183
web-app/src/pages/CharacterDetail.tsx
Normal file
183
web-app/src/pages/CharacterDetail.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link, useNavigate, useParams} from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {ArrowLeft, Book, MessageSquare, Play, Star, Tag} from 'lucide-react'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
|
||||
export default function CharacterDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [character, setCharacter] = useState<Character | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadCharacter(parseInt(id))
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const loadCharacter = async (characterId: number) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await characterApi.getCharacterById(characterId)
|
||||
setCharacter(response.data)
|
||||
} catch (err: any) {
|
||||
console.error('获取角色卡详情失败:', err)
|
||||
alert('角色卡不存在或无权访问')
|
||||
navigate('/market')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getWorldBookStats = () => {
|
||||
if (!character?.characterBook) return null
|
||||
try {
|
||||
const book = typeof character.characterBook === 'string'
|
||||
? JSON.parse(character.characterBook)
|
||||
: character.characterBook
|
||||
return {
|
||||
name: book.name || '未命名世界书',
|
||||
entryCount: book.entries?.length || 0
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartChat = () => {
|
||||
if (!character) return
|
||||
navigate(`/chat?character=${character.id}`)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
|
||||
<div className="text-white">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!character) {
|
||||
return null
|
||||
}
|
||||
|
||||
const worldBookStats = getWorldBookStats()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-32 pb-20 px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Link
|
||||
to="/market"
|
||||
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors mb-6 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回角色广场
|
||||
</Link>
|
||||
|
||||
<div className="glass rounded-3xl overflow-hidden mb-6">
|
||||
<div className="relative h-64 bg-gradient-to-br from-primary/20 to-secondary/20">
|
||||
{character.avatar ? (
|
||||
<img src={character.avatar} alt={character.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-8xl">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0F] to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{character.name}</h1>
|
||||
<p className="text-lg text-white/60">{character.creator || '未知作者'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-white/60">
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>{character.useCount} 次使用</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4" />
|
||||
<span>{character.favoriteCount} 收藏</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{character.tags && character.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{character.tags.map((tag, i) => (
|
||||
<span key={i} className="px-3 py-1 glass rounded-lg text-sm flex items-center gap-1">
|
||||
<Tag className="w-3 h-3" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-white/80 leading-relaxed mb-6">
|
||||
{character.description || '暂无描述'}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleStartChat}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
开始对话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{character.personality && (
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">性格特征</h2>
|
||||
<p className="text-white/80 whitespace-pre-wrap">{character.personality}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.scenario && (
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">场景设定</h2>
|
||||
<p className="text-white/80 whitespace-pre-wrap">{character.scenario}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.firstMes && (
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">第一条消息</h2>
|
||||
<div className="glass-hover rounded-xl p-4">
|
||||
<p className="text-white/80 whitespace-pre-wrap">{character.firstMes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{worldBookStats && worldBookStats.entryCount > 0 && (
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Book className="w-5 h-5 text-secondary" />
|
||||
世界书
|
||||
</h2>
|
||||
<div className="text-white/60">
|
||||
<div>{worldBookStats.name}</div>
|
||||
<div>{worldBookStats.entryCount} 个条目</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
web-app/src/pages/CharacterDetailPage.tsx
Normal file
244
web-app/src/pages/CharacterDetailPage.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link, useNavigate, useParams} from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {ArrowLeft, Book, Calendar, Download, Edit, MessageSquare, Tag, User} from 'lucide-react'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
|
||||
export default function CharacterDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [character, setCharacter] = useState<Character | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadCharacter(parseInt(id))
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const loadCharacter = async (characterId: number) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await characterApi.getCharacterById(characterId)
|
||||
setCharacter(response.data)
|
||||
} catch (err: any) {
|
||||
console.error('获取角色卡详情失败:', err)
|
||||
if (err.response?.status === 401) {
|
||||
navigate('/login')
|
||||
} else {
|
||||
alert('角色卡不存在或无权访问')
|
||||
navigate('/characters')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!character) return
|
||||
try {
|
||||
const response = await characterApi.exportCharacter(character.id)
|
||||
const blob = new Blob([response.data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${character.name}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error('导出角色卡失败:', err)
|
||||
alert('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartChat = () => {
|
||||
if (!character) return
|
||||
navigate(`/chat?character=${character.id}`)
|
||||
}
|
||||
|
||||
const getWorldBookStats = () => {
|
||||
if (!character?.characterBook) return null
|
||||
try {
|
||||
const book = typeof character.characterBook === 'string'
|
||||
? JSON.parse(character.characterBook)
|
||||
: character.characterBook
|
||||
return {
|
||||
name: book.name || '未命名世界书',
|
||||
entryCount: book.entries?.length || 0
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
|
||||
<div className="text-white">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!character) {
|
||||
return null
|
||||
}
|
||||
|
||||
const worldBookStats = getWorldBookStats()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-32 pb-20 px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Link
|
||||
to="/characters"
|
||||
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回角色管理
|
||||
</Link>
|
||||
|
||||
<div className="glass rounded-3xl overflow-hidden">
|
||||
<div className="relative h-64 bg-gradient-to-br from-primary/20 to-secondary/20">
|
||||
{character.avatar && (
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt={character.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0F] to-transparent" />
|
||||
<div className="absolute bottom-6 left-6 right-6">
|
||||
<h1 className="text-4xl font-bold mb-2">{character.name}</h1>
|
||||
{character.creator && (
|
||||
<div className="flex items-center gap-2 text-white/60">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{character.creator}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex gap-3 mb-8">
|
||||
<button
|
||||
onClick={handleStartChat}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
开始对话
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/characters?edit=${character.id}`)}
|
||||
className="px-6 py-3 glass-hover rounded-xl cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-6 py-3 glass-hover rounded-xl cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{character.tags && character.tags.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium">标签</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{character.tags.map((tag, i) => (
|
||||
<span key={i} className="px-3 py-1 glass rounded-lg text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{worldBookStats && (
|
||||
<div className="mb-6 glass rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Book className="w-4 h-4 text-secondary" />
|
||||
<span className="text-sm font-medium">世界书</span>
|
||||
</div>
|
||||
<div className="text-sm text-white/60">
|
||||
<div>{worldBookStats.name}</div>
|
||||
<div>{worldBookStats.entryCount} 个条目</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{character.description && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">角色描述</h3>
|
||||
<p className="text-white/80 whitespace-pre-wrap">{character.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.personality && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">性格特征</h3>
|
||||
<p className="text-white/80 whitespace-pre-wrap">{character.personality}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.scenario && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">场景设定</h3>
|
||||
<p className="text-white/80 whitespace-pre-wrap">{character.scenario}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.firstMes && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">第一条消息</h3>
|
||||
<div className="glass rounded-xl p-4">
|
||||
<p className="text-white/80 whitespace-pre-wrap">{character.firstMes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.mesExample && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">示例对话</h3>
|
||||
<div className="glass rounded-xl p-4">
|
||||
<p className="text-white/80 whitespace-pre-wrap font-mono text-sm">
|
||||
{character.mesExample}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.systemPrompt && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">系统提示词</h3>
|
||||
<div className="glass rounded-xl p-4">
|
||||
<p className="text-white/80 whitespace-pre-wrap font-mono text-sm">
|
||||
{character.systemPrompt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-white/40 pt-4 border-t border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
创建于 {new Date(character.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div>版本 {character.version || '1.0'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
677
web-app/src/pages/CharacterManagePage.tsx
Normal file
677
web-app/src/pages/CharacterManagePage.tsx
Normal file
@@ -0,0 +1,677 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {Book, Download, Edit, FileJson, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
|
||||
interface WorldBookEntry {
|
||||
keys: string[]
|
||||
content: string
|
||||
enabled: boolean
|
||||
insertion_order: number
|
||||
case_sensitive?: boolean
|
||||
name?: string
|
||||
priority?: number
|
||||
id?: number
|
||||
comment?: string
|
||||
selective?: boolean
|
||||
secondary_keys?: string[]
|
||||
constant?: boolean
|
||||
position?: 'before_char' | 'after_char'
|
||||
}
|
||||
|
||||
export default function CharacterManagePage() {
|
||||
const [characters, setCharacters] = useState<Character[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [selectedCharacter, setSelectedCharacter] = useState<Character | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [showWorldBookEditor, setShowWorldBookEditor] = useState(false)
|
||||
const [worldBookEntries, setWorldBookEntries] = useState<WorldBookEntry[]>([])
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
loadCharacters()
|
||||
}, [page, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const editId = searchParams.get('edit')
|
||||
if (editId && characters.length > 0) {
|
||||
const char = characters.find(c => c.id === parseInt(editId))
|
||||
if (char) {
|
||||
setSelectedCharacter(char)
|
||||
setShowEditModal(true)
|
||||
loadWorldBook(char)
|
||||
}
|
||||
}
|
||||
}, [searchParams, characters])
|
||||
|
||||
const loadWorldBook = (character: Character) => {
|
||||
if (!character.characterBook) {
|
||||
setWorldBookEntries([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const book = typeof character.characterBook === 'string'
|
||||
? JSON.parse(character.characterBook)
|
||||
: character.characterBook
|
||||
setWorldBookEntries(book.entries || [])
|
||||
} catch {
|
||||
setWorldBookEntries([])
|
||||
}
|
||||
}
|
||||
|
||||
const loadCharacters = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await characterApi.getCharacterList({
|
||||
page,
|
||||
pageSize: 20,
|
||||
keyword: searchQuery || undefined,
|
||||
})
|
||||
setCharacters(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} catch (err: any) {
|
||||
console.error('获取角色卡列表失败:', err)
|
||||
if (err.response?.status === 401) {
|
||||
navigate('/login')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
await characterApi.uploadCharacter(file)
|
||||
setShowUploadModal(false)
|
||||
loadCharacters()
|
||||
alert('上传成功!')
|
||||
} catch (err: any) {
|
||||
console.error('上传角色卡失败:', err)
|
||||
alert(err.response?.data?.msg || '上传失败,请重试')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async (character: Character) => {
|
||||
try {
|
||||
const response = await characterApi.exportCharacter(character.id)
|
||||
const blob = new Blob([response.data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${character.name}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error('导出角色卡失败:', err)
|
||||
alert('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定要删除这个角色卡吗?')) return
|
||||
|
||||
try {
|
||||
await characterApi.deleteCharacter(id)
|
||||
loadCharacters()
|
||||
alert('删除成功!')
|
||||
} catch (err: any) {
|
||||
console.error('删除角色卡失败:', err)
|
||||
alert(err.response?.data?.msg || '删除失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!selectedCharacter) return
|
||||
|
||||
const formData = new FormData(document.getElementById('edit-form') as HTMLFormElement)
|
||||
|
||||
// 构建世界书数据
|
||||
const characterBook = worldBookEntries.length > 0 ? {
|
||||
name: selectedCharacter.name + ' 的世界书',
|
||||
entries: worldBookEntries
|
||||
} : null
|
||||
|
||||
const updateData = {
|
||||
name: formData.get('name') as string,
|
||||
description: formData.get('description') as string,
|
||||
personality: formData.get('personality') as string,
|
||||
scenario: formData.get('scenario') as string,
|
||||
firstMes: formData.get('firstMes') as string,
|
||||
mesExample: formData.get('mesExample') as string,
|
||||
systemPrompt: formData.get('systemPrompt') as string,
|
||||
tags: (formData.get('tags') as string).split(',').map(t => t.trim()).filter(Boolean),
|
||||
isPublic: formData.get('isPublic') === 'on',
|
||||
characterBook: characterBook,
|
||||
}
|
||||
|
||||
try {
|
||||
await characterApi.updateCharacter(selectedCharacter.id, updateData)
|
||||
setShowEditModal(false)
|
||||
setShowWorldBookEditor(false)
|
||||
loadCharacters()
|
||||
alert('保存成功!')
|
||||
} catch (err: any) {
|
||||
console.error('更新角色卡失败:', err)
|
||||
alert(err.response?.data?.msg || '保存失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const addWorldBookEntry = () => {
|
||||
setWorldBookEntries([...worldBookEntries, {
|
||||
keys: [],
|
||||
content: '',
|
||||
enabled: true,
|
||||
insertion_order: worldBookEntries.length,
|
||||
position: 'after_char'
|
||||
}])
|
||||
}
|
||||
|
||||
const updateWorldBookEntry = (index: number, field: keyof WorldBookEntry, value: any) => {
|
||||
const newEntries = [...worldBookEntries]
|
||||
newEntries[index] = { ...newEntries[index], [field]: value }
|
||||
setWorldBookEntries(newEntries)
|
||||
}
|
||||
|
||||
const deleteWorldBookEntry = (index: number) => {
|
||||
setWorldBookEntries(worldBookEntries.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const getWorldBookEntryCount = (character: Character) => {
|
||||
if (!character.characterBook) return 0
|
||||
try {
|
||||
const book = typeof character.characterBook === 'string'
|
||||
? JSON.parse(character.characterBook)
|
||||
: character.characterBook
|
||||
return book.entries?.length || 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && characters.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
|
||||
<div className="text-white">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-32 pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">角色卡管理</h1>
|
||||
<p className="text-white/60">管理和编辑你的角色卡</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
添加角色卡
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6 mb-8">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索角色名称或描述..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-12 text-center">
|
||||
<p className="text-white/60 mb-4">还没有角色卡</p>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
上传第一个角色卡
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{characters.map((char) => {
|
||||
const worldBookCount = getWorldBookEntryCount(char)
|
||||
return (
|
||||
<div
|
||||
key={char.id}
|
||||
className="glass-hover rounded-2xl overflow-hidden cursor-pointer"
|
||||
onClick={() => navigate(`/my-character/${char.id}`)}
|
||||
>
|
||||
<div className="relative h-48 bg-gradient-to-br from-primary/20 to-secondary/20">
|
||||
{char.avatar ? (
|
||||
<img src={char.avatar} alt={char.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-6xl">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0F] to-transparent" />
|
||||
{worldBookCount > 0 && (
|
||||
<div className="absolute top-3 right-3 px-2 py-1 glass rounded-lg flex items-center gap-1 text-xs">
|
||||
<Book className="w-3 h-3" />
|
||||
{worldBookCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold mb-1 truncate">{char.name}</h3>
|
||||
<p className="text-sm text-white/60 mb-3">{char.creator || '未知作者'}</p>
|
||||
|
||||
{char.tags && char.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{char.tags.slice(0, 3).map((tag, i) => (
|
||||
<span key={i} className="text-xs px-2 py-1 bg-primary/20 text-primary rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-white/60 mb-4 line-clamp-2">
|
||||
{char.description || '暂无描述'}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCharacter(char)
|
||||
setShowEditModal(true)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport(char)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(char.id)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{total > 20 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-4 py-2 glass rounded-lg">
|
||||
{page} / {Math.ceil(total / 20)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= Math.ceil(total / 20)}
|
||||
className="px-4 py-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-3xl p-8 max-w-md w-full">
|
||||
<h2 className="text-2xl font-bold mb-6">上传角色卡</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/80 mb-2">PNG 角色卡</label>
|
||||
<label className="glass-hover rounded-xl p-6 border-2 border-dashed border-white/20 cursor-pointer block text-center">
|
||||
<ImageIcon className="w-8 h-8 mx-auto mb-2 text-white/40" />
|
||||
<span className="text-sm text-white/60">
|
||||
{uploading ? '上传中...' : '点击上传 PNG 格式角色卡'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".png"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-white/40 text-sm">或</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-white/80 mb-2">JSON 角色卡</label>
|
||||
<label className="glass-hover rounded-xl p-6 border-2 border-dashed border-white/20 cursor-pointer block text-center">
|
||||
<FileJson className="w-8 h-8 mx-auto mb-2 text-white/40" />
|
||||
<span className="text-sm text-white/60">
|
||||
{uploading ? '上传中...' : '点击上传 JSON 格式角色卡'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
disabled={uploading}
|
||||
className="w-full mt-6 px-6 py-3 glass-hover rounded-xl cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditModal && selectedCharacter && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="glass rounded-3xl p-8 max-w-2xl w-full my-8">
|
||||
<h2 className="text-2xl font-bold mb-6">编辑角色卡</h2>
|
||||
|
||||
<form id="edit-form" className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">角色名称</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
defaultValue={selectedCharacter.name}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">角色描述</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={selectedCharacter.description}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">性格特征</label>
|
||||
<textarea
|
||||
name="personality"
|
||||
rows={3}
|
||||
defaultValue={selectedCharacter.personality}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">场景设定</label>
|
||||
<textarea
|
||||
name="scenario"
|
||||
rows={3}
|
||||
defaultValue={selectedCharacter.scenario}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">第一条消息</label>
|
||||
<textarea
|
||||
name="firstMes"
|
||||
rows={3}
|
||||
defaultValue={selectedCharacter.firstMes}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">示例对话</label>
|
||||
<textarea
|
||||
name="mesExample"
|
||||
rows={4}
|
||||
defaultValue={selectedCharacter.mesExample}
|
||||
placeholder="<START> {{user}}: 你好 {{char}}: 你好!很高兴见到你。"
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">系统提示词</label>
|
||||
<textarea
|
||||
name="systemPrompt"
|
||||
rows={3}
|
||||
defaultValue={selectedCharacter.systemPrompt}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">标签(逗号分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tags"
|
||||
defaultValue={selectedCharacter.tags?.join(', ')}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isPublic"
|
||||
defaultChecked={selectedCharacter.isPublic}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-white/80">公开到角色广场</span>
|
||||
</label>
|
||||
<p className="text-xs text-white/40 mt-1">公开后其他用户可以在角色广场看到并使用此角色卡</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWorldBookEditor(true)}
|
||||
className="w-full px-4 py-3 glass-hover rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
编辑世界书 ({getWorldBookEntryCount(selectedCharacter)} 个条目)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="flex-1 px-6 py-3 glass-hover rounded-xl cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWorldBookEditor && selectedCharacter && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-3xl p-8 max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">编辑世界书</h2>
|
||||
<button
|
||||
onClick={() => setShowWorldBookEditor(false)}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
|
||||
{worldBookEntries.length === 0 ? (
|
||||
<div className="glass rounded-xl p-8 text-center">
|
||||
<Book className="w-12 h-12 mx-auto mb-3 text-white/40" />
|
||||
<p className="text-white/60 mb-4">还没有世界书条目</p>
|
||||
<button
|
||||
onClick={addWorldBookEntry}
|
||||
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
|
||||
>
|
||||
添加第一个条目
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
worldBookEntries.map((entry, index) => (
|
||||
<div key={index} className="glass rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium">条目 #{index + 1}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entry.enabled}
|
||||
onChange={(e) => updateWorldBookEntry(index, 'enabled', e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-white/60">启用</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => deleteWorldBookEntry(index)}
|
||||
className="p-1 glass-hover rounded text-red-400 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">触发关键词(逗号分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.keys.join(', ')}
|
||||
onChange={(e) => updateWorldBookEntry(index, 'keys', e.target.value.split(',').map(k => k.trim()).filter(Boolean))}
|
||||
placeholder="例如: 魔法, 咒语, 法术"
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">内容</label>
|
||||
<textarea
|
||||
value={entry.content}
|
||||
onChange={(e) => updateWorldBookEntry(index, 'content', e.target.value)}
|
||||
placeholder="当触发关键词时,这段内容会被添加到对话上下文中..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">插入位置</label>
|
||||
<select
|
||||
value={entry.position || 'after_char'}
|
||||
onChange={(e) => updateWorldBookEntry(index, 'position', e.target.value)}
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="before_char">角色描述之前</option>
|
||||
<option value="after_char">角色描述之后</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">插入顺序</label>
|
||||
<input
|
||||
type="number"
|
||||
value={entry.insertion_order}
|
||||
onChange={(e) => updateWorldBookEntry(index, 'insertion_order', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6 pt-6 border-t border-white/10">
|
||||
<button
|
||||
onClick={addWorldBookEntry}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
添加条目
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setShowWorldBookEditor(false)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowWorldBookEditor(false)
|
||||
// 世界书数据会在保存角色卡时一起保存
|
||||
}}
|
||||
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
web-app/src/pages/CharacterMarket.tsx
Normal file
206
web-app/src/pages/CharacterMarket.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link, useNavigate} from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {Book, MessageSquare, Search, Star} from 'lucide-react'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
|
||||
const categories = ['全部', '冒险', '知识', '娱乐', '情感', '科幻', '奇幻', '历史']
|
||||
|
||||
export default function CharacterMarket() {
|
||||
const [characters, setCharacters] = useState<Character[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState('全部')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
loadCharacters()
|
||||
}, [page, searchQuery, selectedCategory])
|
||||
|
||||
const loadCharacters = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await characterApi.getCharacterList({
|
||||
page,
|
||||
pageSize: 20,
|
||||
keyword: searchQuery || undefined,
|
||||
tag: selectedCategory !== '全部' ? selectedCategory : undefined,
|
||||
isPublic: true, // 只获取公开的角色卡
|
||||
})
|
||||
setCharacters(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} catch (err: any) {
|
||||
console.error('获取角色卡列表失败:', err)
|
||||
if (err.response?.status === 401) {
|
||||
navigate('/login')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getWorldBookCount = (character: Character) => {
|
||||
if (!character.characterBook) return 0
|
||||
try {
|
||||
const book = typeof character.characterBook === 'string'
|
||||
? JSON.parse(character.characterBook)
|
||||
: character.characterBook
|
||||
return book.entries?.length || 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && characters.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
|
||||
<div className="text-white">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-32 pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">角色广场</h1>
|
||||
<p className="text-white/60">探索并选择你喜欢的 AI 角色</p>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6 mb-8">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索角色名称或描述..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4 overflow-x-auto pb-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
setSelectedCategory(cat)
|
||||
setPage(1)
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm whitespace-nowrap cursor-pointer transition-all ${
|
||||
selectedCategory === cat
|
||||
? 'bg-gradient-to-r from-primary to-secondary'
|
||||
: 'glass-hover'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-12 text-center">
|
||||
<p className="text-white/60">暂无公开角色卡</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{characters.map((char) => {
|
||||
const worldBookCount = getWorldBookCount(char)
|
||||
return (
|
||||
<Link
|
||||
key={char.id}
|
||||
to={`/character/${char.id}`}
|
||||
className="glass-hover rounded-2xl overflow-hidden cursor-pointer group"
|
||||
>
|
||||
<div className="relative h-48 bg-gradient-to-br from-primary/20 to-secondary/20">
|
||||
{char.avatar ? (
|
||||
<img src={char.avatar} alt={char.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-6xl">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0F] to-transparent" />
|
||||
{worldBookCount > 0 && (
|
||||
<div className="absolute top-3 right-3 px-2 py-1 glass rounded-lg flex items-center gap-1 text-xs">
|
||||
<Book className="w-3 h-3" />
|
||||
{worldBookCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold mb-1 group-hover:text-primary transition-colors truncate">
|
||||
{char.name}
|
||||
</h3>
|
||||
<p className="text-sm text-white/60 mb-3">{char.creator || '未知作者'}</p>
|
||||
<p className="text-xs text-white/40 mb-4 line-clamp-2">
|
||||
{char.description || '暂无描述'}
|
||||
</p>
|
||||
|
||||
{char.tags && char.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{char.tags.slice(0, 2).map((tag, i) => (
|
||||
<span key={i} className="text-xs px-2 py-1 bg-primary/20 text-primary rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-white/40 pt-4 border-t border-white/10">
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span>{char.useCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
<span>{char.favoriteCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{total > 20 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-4 py-2 glass rounded-lg">
|
||||
{page} / {Math.ceil(total / 20)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= Math.ceil(total / 20)}
|
||||
className="px-4 py-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
web-app/src/pages/ChatPage.tsx
Normal file
208
web-app/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom'
|
||||
import {PanelLeftOpen, PanelRightOpen} from 'lucide-react'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import ChatArea from '../components/ChatArea'
|
||||
import CharacterPanel from '../components/CharacterPanel'
|
||||
import SettingsPanel from '../components/SettingsPanel'
|
||||
import {type Conversation, conversationApi} from '../api/conversation'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
|
||||
export default function ChatPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [activePanel, setActivePanel] = useState<'chat' | 'settings'>('chat')
|
||||
const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null)
|
||||
const [currentCharacter, setCurrentCharacter] = useState<Character | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [backgroundImage, setBackgroundImage] = useState<string>()
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [showCharacterPanel, setShowCharacterPanel] = useState(true)
|
||||
|
||||
// 从URL参数获取角色ID或对话ID
|
||||
const characterId = searchParams.get('character')
|
||||
const conversationId = searchParams.get('conversation')
|
||||
|
||||
useEffect(() => {
|
||||
initializeChat()
|
||||
}, [characterId, conversationId])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentConversation?.settings) {
|
||||
try {
|
||||
const settings = typeof currentConversation.settings === 'string'
|
||||
? JSON.parse(currentConversation.settings)
|
||||
: currentConversation.settings
|
||||
|
||||
// 设置背景图(如果有)
|
||||
if (settings.backgroundImage) {
|
||||
setBackgroundImage(settings.backgroundImage)
|
||||
} else {
|
||||
// 如果当前对话没有背景图,清除背景图
|
||||
setBackgroundImage(undefined)
|
||||
}
|
||||
|
||||
// 设置主题色
|
||||
if (settings.themeColor) {
|
||||
document.documentElement.style.setProperty('--color-primary', settings.themeColor)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析设置失败:', e)
|
||||
}
|
||||
} else {
|
||||
// 如果没有设置,清除背景图
|
||||
setBackgroundImage(undefined)
|
||||
}
|
||||
}, [currentConversation?.settings, currentConversation?.id])
|
||||
|
||||
const initializeChat = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// 如果有对话ID,加载现有对话
|
||||
if (conversationId) {
|
||||
const convResp = await conversationApi.getConversationById(Number(conversationId))
|
||||
setCurrentConversation(convResp.data)
|
||||
|
||||
// 加载对话关联的角色
|
||||
const charResp = await characterApi.getCharacterById(convResp.data.characterId)
|
||||
setCurrentCharacter(charResp.data)
|
||||
}
|
||||
// 如果有角色ID,创建新对话
|
||||
else if (characterId) {
|
||||
const charResp = await characterApi.getCharacterById(Number(characterId))
|
||||
setCurrentCharacter(charResp.data)
|
||||
|
||||
// 创建新对话
|
||||
const convResp = await conversationApi.createConversation({
|
||||
characterId: Number(characterId),
|
||||
title: `与 ${charResp.data.name} 的对话`,
|
||||
})
|
||||
setCurrentConversation(convResp.data)
|
||||
|
||||
// 更新URL为对话ID
|
||||
navigate(`/chat?conversation=${convResp.data.id}`, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('初始化对话失败:', err)
|
||||
alert('加载对话失败,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConversationChange = (conversation: Conversation) => {
|
||||
setCurrentConversation(conversation)
|
||||
navigate(`/chat?conversation=${conversation.id}`)
|
||||
}
|
||||
|
||||
const handleSettingsUpdate = (settings: any) => {
|
||||
if (settings.backgroundImage !== undefined) {
|
||||
setBackgroundImage(settings.backgroundImage)
|
||||
}
|
||||
if (settings.themeColor) {
|
||||
document.documentElement.style.setProperty('--color-primary', settings.themeColor)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<div className="text-white">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 如果没有对话和角色,显示带侧边栏的空状态
|
||||
if (!currentConversation || !currentCharacter) {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden flex bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<Sidebar
|
||||
currentConversationId={undefined}
|
||||
onConversationChange={handleConversationChange}
|
||||
/>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-white/60 mb-4">请选择或创建对话</p>
|
||||
<button
|
||||
onClick={() => navigate('/characters')}
|
||||
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
选择角色开始对话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden flex bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] relative">
|
||||
{backgroundImage && (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-20"
|
||||
style={{ backgroundImage: `url(${backgroundImage})` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex w-full">
|
||||
{/* 左侧边栏 */}
|
||||
{showSidebar && (
|
||||
<Sidebar
|
||||
currentConversationId={currentConversation.id}
|
||||
onConversationChange={handleConversationChange}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 左侧边栏切换按钮 */}
|
||||
{!showSidebar && (
|
||||
<button
|
||||
onClick={() => setShowSidebar(true)}
|
||||
className="fixed left-2 top-4 z-50 p-2 glass-hover rounded-lg cursor-pointer shadow-lg"
|
||||
title="显示对话列表"
|
||||
>
|
||||
<PanelLeftOpen className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex">
|
||||
{activePanel === 'chat' ? (
|
||||
<>
|
||||
<ChatArea
|
||||
conversation={currentConversation}
|
||||
character={currentCharacter}
|
||||
onConversationUpdate={setCurrentConversation}
|
||||
/>
|
||||
{showCharacterPanel && (
|
||||
<CharacterPanel
|
||||
character={currentCharacter}
|
||||
conversation={currentConversation}
|
||||
onOpenSettings={() => setActivePanel('settings')}
|
||||
onClose={() => setShowCharacterPanel(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧角色面板切换按钮 */}
|
||||
{!showCharacterPanel && (
|
||||
<button
|
||||
onClick={() => setShowCharacterPanel(true)}
|
||||
className="fixed right-2 top-4 z-50 p-2 glass-hover rounded-lg cursor-pointer shadow-lg"
|
||||
title="显示角色信息"
|
||||
>
|
||||
<PanelRightOpen className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<SettingsPanel
|
||||
conversation={currentConversation}
|
||||
onClose={() => setActivePanel('chat')}
|
||||
onUpdate={handleSettingsUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
web-app/src/pages/ForgotPasswordPage.tsx
Normal file
87
web-app/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {Link} from 'react-router-dom'
|
||||
import {ArrowLeft, Mail, Sparkles} from 'lucide-react'
|
||||
import {useState} from 'react'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
云酒馆
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="glass rounded-3xl p-8">
|
||||
{!submitted ? (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold mb-2 text-center">忘记密码</h1>
|
||||
<p className="text-white/60 text-center mb-8">
|
||||
输入你的邮箱地址,我们将发送重置密码的链接
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">邮箱</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
发送重置链接
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
to="/login"
|
||||
className="mt-6 flex items-center justify-center gap-2 text-sm text-white/60 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回登录
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Mail className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">邮件已发送</h1>
|
||||
<p className="text-white/60 mb-8">
|
||||
我们已向 <span className="text-primary">{email}</span> 发送了重置密码的链接,请查收邮件。
|
||||
</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
web-app/src/pages/HomePage.tsx
Normal file
132
web-app/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import {Link} from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {ArrowRight, MessageSquare, Sparkles, Star, Users, Zap} from 'lucide-react'
|
||||
|
||||
const featuredCharacters = [
|
||||
{ id: 1, name: '艾莉娅', role: '冒险向导', avatar: '🧝♀️', chats: 12500, rating: 4.9 },
|
||||
{ id: 2, name: '智者墨菲', role: '知识导师', avatar: '🧙♂️', chats: 8900, rating: 4.8 },
|
||||
{ id: 3, name: '赛博朋克', role: '未来战士', avatar: '🤖', chats: 15200, rating: 4.9 },
|
||||
{ id: 4, name: '月影', role: '神秘刺客', avatar: '🥷', chats: 10300, rating: 4.7 },
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<Navbar />
|
||||
|
||||
<section className="pt-32 pb-20 px-4">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 glass rounded-full px-4 py-2 mb-6">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm">AI 驱动的角色对话平台</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight">
|
||||
与 AI 角色
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
开启无限对话
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-white/60 mb-8 max-w-2xl mx-auto">
|
||||
探索数千个精心设计的 AI 角色,从冒险伙伴到知识导师,开启你的专属对话体验
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/market"
|
||||
className="px-8 py-4 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer inline-flex items-center gap-2"
|
||||
>
|
||||
探索角色广场
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
<button className="px-8 py-4 glass-hover rounded-xl font-medium cursor-pointer">
|
||||
了解更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="glass-hover rounded-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-primary/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">丰富角色库</h3>
|
||||
<p className="text-white/60">数千个精心设计的 AI 角色,涵盖各种类型和风格</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-hover rounded-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-secondary/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<MessageSquare className="w-8 h-8 text-secondary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">智能对话</h3>
|
||||
<p className="text-white/60">基于先进 AI 技术,提供自然流畅的对话体验</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-hover rounded-2xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-cta/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Zap className="w-8 h-8 text-cta" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">即时响应</h3>
|
||||
<p className="text-white/60">快速响应,无需等待,随时随地开启对话</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-2">热门角色</h2>
|
||||
<p className="text-white/60">最受欢迎的 AI 角色</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/market"
|
||||
className="text-primary hover:text-secondary transition-colors cursor-pointer inline-flex items-center gap-2"
|
||||
>
|
||||
查看全部
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-6">
|
||||
{featuredCharacters.map((char) => (
|
||||
<Link
|
||||
key={char.id}
|
||||
to={`/character/${char.id}`}
|
||||
className="glass-hover rounded-2xl p-6 text-center cursor-pointer group"
|
||||
>
|
||||
<div className="text-6xl mb-4">{char.avatar}</div>
|
||||
<h3 className="text-lg font-semibold mb-1 group-hover:text-primary transition-colors">
|
||||
{char.name}
|
||||
</h3>
|
||||
<p className="text-sm text-white/60 mb-4">{char.role}</p>
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-white/40">
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span>{(char.chats / 1000).toFixed(1)}k</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
<span>{char.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="py-12 px-4 border-t border-white/10">
|
||||
<div className="max-w-6xl mx-auto text-center text-white/40 text-sm">
|
||||
<p>© 2024 云酒馆. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
web-app/src/pages/LoginPage.tsx
Normal file
142
web-app/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import {useState} from 'react'
|
||||
import {Link, useNavigate} from 'react-router-dom'
|
||||
import {Eye, EyeOff, Lock, Mail, Sparkles} from 'lucide-react'
|
||||
import {authApi} from '../api/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await authApi.login({ username, password })
|
||||
const { token, refreshToken, user } = response.data
|
||||
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('refreshToken', refreshToken)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
|
||||
navigate('/profile')
|
||||
} catch (err: any) {
|
||||
setError(err || '登录失败,请检查用户名和密码')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
云酒馆
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="glass rounded-3xl p-8">
|
||||
<h1 className="text-2xl font-bold mb-2 text-center">欢迎回来</h1>
|
||||
<p className="text-white/60 text-center mb-8">登录继续你的对话之旅</p>
|
||||
|
||||
{error && (
|
||||
<div className="glass rounded-xl p-4 border border-red-500/50 bg-red-500/10 mb-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">用户名</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-12 pr-12 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60 cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="cursor-pointer" />
|
||||
<span className="text-white/60">记住我</span>
|
||||
</label>
|
||||
<Link to="/forgot-password" className="text-primary hover:text-secondary transition-colors cursor-pointer">
|
||||
忘记密码?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-white/60">
|
||||
还没有账号?
|
||||
<Link to="/register" className="text-primary hover:text-secondary transition-colors ml-1 cursor-pointer">
|
||||
立即注册
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-4 glass text-white/60">或使用第三方登录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<button className="py-3 glass-hover rounded-xl text-sm cursor-pointer">
|
||||
GitHub
|
||||
</button>
|
||||
<button className="py-3 glass-hover rounded-xl text-sm cursor-pointer">
|
||||
Google
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
836
web-app/src/pages/PresetManagePage.tsx
Normal file
836
web-app/src/pages/PresetManagePage.tsx
Normal file
@@ -0,0 +1,836 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {Check, Copy, Download, Edit, FileJson, Plus, Search, Star, Trash2, Upload} from 'lucide-react'
|
||||
import {type Preset, presetApi} from '../api/preset'
|
||||
|
||||
export default function PresetManagePage() {
|
||||
const [presets, setPresets] = useState<Preset[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedPreset, setSelectedPreset] = useState<Preset | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
// 加载预设列表
|
||||
useEffect(() => {
|
||||
loadPresets()
|
||||
}, [page, searchQuery])
|
||||
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await presetApi.getPresetList({
|
||||
page,
|
||||
pageSize: 20,
|
||||
keyword: searchQuery
|
||||
})
|
||||
setPresets(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} catch (err) {
|
||||
console.error('加载预设失败:', err)
|
||||
alert('加载预设失败,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 文件上传导入
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
await presetApi.importPreset(file)
|
||||
await loadPresets()
|
||||
setShowUploadModal(false)
|
||||
alert('预设导入成功!')
|
||||
} catch (err: any) {
|
||||
console.error('导入预设失败:', err)
|
||||
alert(err.response?.data?.msg || '导入失败,请检查文件格式')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出预设
|
||||
const handleExport = async (preset: Preset) => {
|
||||
try {
|
||||
const response = await presetApi.exportPreset(preset.id)
|
||||
const blob = new Blob([response.data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${preset.name}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error('导出预设失败:', err)
|
||||
alert('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 复制预设
|
||||
const handleCopy = async (preset: Preset) => {
|
||||
try {
|
||||
await presetApi.createPreset({
|
||||
name: `${preset.name} (副本)`,
|
||||
description: preset.description,
|
||||
temperature: preset.temperature,
|
||||
topP: preset.topP,
|
||||
topK: preset.topK,
|
||||
frequencyPenalty: preset.frequencyPenalty,
|
||||
presencePenalty: preset.presencePenalty,
|
||||
maxTokens: preset.maxTokens,
|
||||
repetitionPenalty: preset.repetitionPenalty,
|
||||
minP: preset.minP,
|
||||
topA: preset.topA,
|
||||
systemPrompt: preset.systemPrompt,
|
||||
stopSequences: preset.stopSequences,
|
||||
extensions: preset.extensions
|
||||
})
|
||||
await loadPresets()
|
||||
setCopiedId(preset.id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch (err) {
|
||||
console.error('复制预设失败:', err)
|
||||
alert('复制失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除预设
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定要删除这个预设吗?')) return
|
||||
|
||||
try {
|
||||
await presetApi.deletePreset(id)
|
||||
await loadPresets()
|
||||
} catch (err: any) {
|
||||
console.error('删除预设失败:', err)
|
||||
alert(err.response?.data?.msg || '删除失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认预设
|
||||
const handleSetDefault = async (id: number) => {
|
||||
try {
|
||||
await presetApi.setDefaultPreset(id)
|
||||
await loadPresets()
|
||||
alert('已设置为默认预设')
|
||||
} catch (err) {
|
||||
console.error('设置默认预设失败:', err)
|
||||
alert('设置失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = async (updatedPreset: Preset) => {
|
||||
try {
|
||||
await presetApi.updatePreset(updatedPreset.id, {
|
||||
name: updatedPreset.name,
|
||||
description: updatedPreset.description,
|
||||
temperature: updatedPreset.temperature,
|
||||
topP: updatedPreset.topP,
|
||||
topK: updatedPreset.topK,
|
||||
frequencyPenalty: updatedPreset.frequencyPenalty,
|
||||
presencePenalty: updatedPreset.presencePenalty,
|
||||
maxTokens: updatedPreset.maxTokens,
|
||||
repetitionPenalty: updatedPreset.repetitionPenalty,
|
||||
minP: updatedPreset.minP,
|
||||
topA: updatedPreset.topA,
|
||||
systemPrompt: updatedPreset.systemPrompt,
|
||||
stopSequences: updatedPreset.stopSequences,
|
||||
extensions: updatedPreset.extensions
|
||||
})
|
||||
await loadPresets()
|
||||
setShowEditModal(false)
|
||||
alert('保存成功')
|
||||
} catch (err) {
|
||||
console.error('保存预设失败:', err)
|
||||
alert('保存失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新预设
|
||||
const handleCreate = async (newPreset: Partial<Preset>) => {
|
||||
try {
|
||||
await presetApi.createPreset({
|
||||
name: newPreset.name!,
|
||||
description: newPreset.description,
|
||||
temperature: newPreset.temperature ?? 0.7,
|
||||
topP: newPreset.topP ?? 0.9,
|
||||
topK: newPreset.topK ?? 40,
|
||||
frequencyPenalty: newPreset.frequencyPenalty ?? 0,
|
||||
presencePenalty: newPreset.presencePenalty ?? 0,
|
||||
maxTokens: newPreset.maxTokens ?? 2048,
|
||||
repetitionPenalty: newPreset.repetitionPenalty ?? 1.0,
|
||||
minP: newPreset.minP ?? 0,
|
||||
topA: newPreset.topA ?? 0,
|
||||
systemPrompt: newPreset.systemPrompt ?? '',
|
||||
stopSequences: newPreset.stopSequences ?? [],
|
||||
extensions: newPreset.extensions ?? {}
|
||||
})
|
||||
await loadPresets()
|
||||
setShowCreateModal(false)
|
||||
alert('创建成功')
|
||||
} catch (err) {
|
||||
console.error('创建预设失败:', err)
|
||||
alert('创建失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPresets = presets
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-32 pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">预设管理</h1>
|
||||
<p className="text-white/60">管理对话预设参数</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
新建预设
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 glass-hover rounded-xl font-medium cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
导入预设
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6 mb-8">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索预设名称或描述..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-white/60 py-12">加载中...</div>
|
||||
) : filteredPresets.length === 0 ? (
|
||||
<div className="text-center text-white/60 py-12">
|
||||
<p className="mb-2">还没有预设</p>
|
||||
<p className="text-sm">创建或导入一个预设开始使用吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{filteredPresets.map((preset) => (
|
||||
<div key={preset.id} className="glass-hover rounded-2xl p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
||||
{preset.isDefault && (
|
||||
<span className="text-xs px-2 py-1 bg-primary/20 text-primary rounded flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{preset.isPublic && (
|
||||
<span className="text-xs px-2 py-1 bg-secondary/20 text-secondary rounded">
|
||||
公开
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-white/60">{preset.description}</p>
|
||||
<p className="text-xs text-white/40 mt-2">使用次数: {preset.useCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
|
||||
<div className="glass rounded-lg p-3">
|
||||
<div className="text-white/60 mb-1">温度</div>
|
||||
<div className="font-medium">{preset.temperature}</div>
|
||||
</div>
|
||||
<div className="glass rounded-lg p-3">
|
||||
<div className="text-white/60 mb-1">最大令牌</div>
|
||||
<div className="font-medium">{preset.maxTokens}</div>
|
||||
</div>
|
||||
<div className="glass rounded-lg p-3">
|
||||
<div className="text-white/60 mb-1">Top P</div>
|
||||
<div className="font-medium">{preset.topP}</div>
|
||||
</div>
|
||||
<div className="glass rounded-lg p-3">
|
||||
<div className="text-white/60 mb-1">Top K</div>
|
||||
<div className="font-medium">{preset.topK}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPreset(preset)
|
||||
setShowEditModal(true)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
编辑
|
||||
</button>
|
||||
{!preset.isDefault && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(preset.id)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
|
||||
title="设为默认"
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCopy(preset)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
|
||||
title="复制预设"
|
||||
>
|
||||
{copiedId === preset.id ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport(preset)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer"
|
||||
title="导出预设"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(preset.id)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer text-red-400"
|
||||
title="删除预设"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{total > 20 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-4 py-2 glass rounded-lg text-sm">
|
||||
第 {page} 页 / 共 {Math.ceil(total / 20)} 页
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= Math.ceil(total / 20)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导入模态框 */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-3xl p-8 max-w-md w-full">
|
||||
<h2 className="text-2xl font-bold mb-6">导入预设</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-white/80 mb-2">JSON 预设文件</label>
|
||||
<label className="glass-hover rounded-xl p-6 border-2 border-dashed border-white/20 cursor-pointer block text-center">
|
||||
<FileJson className="w-8 h-8 mx-auto mb-2 text-white/40" />
|
||||
<span className="text-sm text-white/60">点击上传 JSON 格式预设</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-white/40 mt-2">
|
||||
支持 SillyTavern、TavernAI 等格式的预设文件
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className="w-full mt-6 px-6 py-3 glass-hover rounded-xl cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑模态框 */}
|
||||
{showEditModal && selectedPreset && (
|
||||
<EditPresetModal
|
||||
preset={selectedPreset}
|
||||
onSave={handleSaveEdit}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 创建模态框 */}
|
||||
{showCreateModal && (
|
||||
<CreatePresetModal
|
||||
onCreate={handleCreate}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑预设模态框
|
||||
function EditPresetModal({ preset, onSave, onClose }: {
|
||||
preset: Preset
|
||||
onSave: (preset: Preset) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
// 解析 extensions 中的 prompts
|
||||
const getPrompts = () => {
|
||||
const ext = preset.extensions as any
|
||||
if (ext?.prompts && Array.isArray(ext.prompts)) {
|
||||
return ext.prompts
|
||||
}
|
||||
// 默认提示词结构
|
||||
return [
|
||||
{ identifier: 'main', name: 'Main Prompt', content: '', role: 'system', system_prompt: true },
|
||||
{ identifier: 'nsfw', name: 'Auxiliary Prompt', content: '', role: 'system', system_prompt: true },
|
||||
{ identifier: 'jailbreak', name: 'Post-History Instructions', content: '', role: 'system', system_prompt: true },
|
||||
]
|
||||
}
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
...preset,
|
||||
description: preset.description || '',
|
||||
temperature: preset.temperature ?? 0.7,
|
||||
maxTokens: preset.maxTokens ?? 2048,
|
||||
topP: preset.topP ?? 0.9,
|
||||
topK: preset.topK ?? 40,
|
||||
frequencyPenalty: preset.frequencyPenalty ?? 0,
|
||||
presencePenalty: preset.presencePenalty ?? 0,
|
||||
repetitionPenalty: preset.repetitionPenalty ?? 1.0,
|
||||
minP: preset.minP ?? 0,
|
||||
topA: preset.topA ?? 0,
|
||||
systemPrompt: preset.systemPrompt || '',
|
||||
stopSequences: preset.stopSequences || [],
|
||||
extensions: preset.extensions || {}
|
||||
})
|
||||
|
||||
const [prompts, setPrompts] = useState(getPrompts())
|
||||
const [activeTab, setActiveTab] = useState<'params' | 'prompts'>('params')
|
||||
|
||||
const handlePromptChange = (index: number, field: string, value: any) => {
|
||||
const newPrompts = [...prompts]
|
||||
newPrompts[index] = { ...newPrompts[index], [field]: value }
|
||||
setPrompts(newPrompts)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// 更新 extensions 中的 prompts
|
||||
const updatedExtensions = {
|
||||
...(formData.extensions as any),
|
||||
prompts: prompts
|
||||
}
|
||||
|
||||
onSave({
|
||||
...formData,
|
||||
extensions: updatedExtensions
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="glass rounded-3xl p-8 max-w-4xl w-full my-8">
|
||||
<h2 className="text-2xl font-bold mb-6">编辑预设</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">预设名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">描述</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签页切换 */}
|
||||
<div className="flex gap-2 border-b border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('params')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'params'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-white/60 hover:text-white/80'
|
||||
}`}
|
||||
>
|
||||
采样参数
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('prompts')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'prompts'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-white/60 hover:text-white/80'
|
||||
}`}
|
||||
>
|
||||
提示词管理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto pr-2">
|
||||
{activeTab === 'params' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">
|
||||
温度 (Temperature): {formData.temperature}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={formData.temperature}
|
||||
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-white/40 mt-1">控制输出的随机性</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">
|
||||
最大令牌 (Max Tokens): {formData.maxTokens}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.maxTokens}
|
||||
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
min="1"
|
||||
max="32000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">
|
||||
Top P: {formData.topP}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={formData.topP}
|
||||
onChange={(e) => setFormData({ ...formData, topP: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">
|
||||
Top K: {formData.topK}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.topK}
|
||||
onChange={(e) => setFormData({ ...formData, topK: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">
|
||||
频率惩罚: {formData.frequencyPenalty}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={formData.frequencyPenalty}
|
||||
onChange={(e) => setFormData({ ...formData, frequencyPenalty: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">
|
||||
存在惩罚: {formData.presencePenalty}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={formData.presencePenalty}
|
||||
onChange={(e) => setFormData({ ...formData, presencePenalty: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">停止序列(每行一个)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={(formData.stopSequences || []).join('\n')}
|
||||
onChange={(e) => setFormData({ ...formData, stopSequences: e.target.value.split('\n').filter(s => s.trim()) })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
placeholder="输入停止序列..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-white/60 mb-4">
|
||||
管理对话提示词的内容和顺序,控制 AI 的行为和响应方式
|
||||
</p>
|
||||
{prompts.map((prompt: any, index: number) => (
|
||||
<div key={index} className="glass rounded-xl p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-sm font-medium">{prompt.name || prompt.identifier}</span>
|
||||
{prompt.identifier && (
|
||||
<span className="text-xs text-white/40 px-2 py-0.5 glass rounded">
|
||||
{prompt.identifier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!prompt.marker}
|
||||
onChange={(e) => handlePromptChange(index, 'marker', !e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-white/60">启用</span>
|
||||
</label>
|
||||
</div>
|
||||
{!prompt.marker && (
|
||||
<textarea
|
||||
rows={3}
|
||||
value={prompt.content || ''}
|
||||
onChange={(e) => handlePromptChange(index, 'content', e.target.value)}
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
placeholder={`输入 ${prompt.name} 的内容...`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 glass-hover rounded-xl cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 创建预设模态框
|
||||
function CreatePresetModal({ onCreate, onClose }: {
|
||||
onCreate: (preset: Partial<Preset>) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
frequencyPenalty: 0,
|
||||
presencePenalty: 0,
|
||||
repetitionPenalty: 1.0,
|
||||
minP: 0,
|
||||
topA: 0,
|
||||
systemPrompt: '',
|
||||
stopSequences: [] as string[],
|
||||
extensions: {} as Record<string, any>
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onCreate(formData)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="glass rounded-3xl p-8 max-w-2xl w-full my-8">
|
||||
<h2 className="text-2xl font-bold mb-6">创建新预设</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">预设名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">描述</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">
|
||||
温度: {formData.temperature}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={formData.temperature}
|
||||
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">
|
||||
最大令牌: {formData.maxTokens}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.maxTokens}
|
||||
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
min="1"
|
||||
max="32000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">系统提示词</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={formData.systemPrompt}
|
||||
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
placeholder="设置 AI 的行为和角色..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">停止序列(每行一个)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.stopSequences.join('\n')}
|
||||
onChange={(e) => setFormData({ ...formData, stopSequences: e.target.value.split('\n').filter(s => s.trim()) })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
placeholder="输入停止序列..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">扩展配置(JSON 格式)</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={JSON.stringify(formData.extensions, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value)
|
||||
setFormData({ ...formData, extensions: parsed })
|
||||
} catch (err) {
|
||||
// 保持原值,等待用户输入完整的 JSON
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none font-mono"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
<p className="text-xs text-white/40 mt-1">用于 SillyTavern 扩展配置</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 glass-hover rounded-xl cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
295
web-app/src/pages/ProfilePage.tsx
Normal file
295
web-app/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link, useNavigate} from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {Bell, Heart, LogOut, MessageSquare, Settings, Shield, Star, User} from 'lucide-react'
|
||||
import {authApi, type User as UserType} from '../api/auth'
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'settings' | 'favorites'>('overview')
|
||||
const [user, setUser] = useState<UserType | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
loadUserInfo()
|
||||
}, [])
|
||||
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
const response = await authApi.getUserInfo()
|
||||
setUser(response.data)
|
||||
} catch (err) {
|
||||
console.error('获取用户信息失败:', err)
|
||||
navigate('/login')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch (err) {
|
||||
console.error('登出失败:', err)
|
||||
} finally {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('user')
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center">
|
||||
<div className="text-white">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const recentChats = [
|
||||
{ id: 1, character: '艾莉娅', lastMessage: '那么,准备好下一次冒险了吗?', time: '2分钟前' },
|
||||
{ id: 2, character: '智者墨菲', lastMessage: '这个问题很有深度...', time: '1小时前' },
|
||||
{ id: 3, character: '赛博朋克', lastMessage: '未来已来,你准备好了吗?', time: '3小时前' },
|
||||
]
|
||||
|
||||
const favoriteCharacters = [
|
||||
{ id: 1, name: '艾莉娅', avatar: '🧝♀️', role: '冒险向导' },
|
||||
{ id: 2, name: '智者墨菲', avatar: '🧙♂️', role: '知识导师' },
|
||||
{ id: 3, name: '月影', avatar: '🥷', role: '神秘刺客' },
|
||||
{ id: 4, name: '心理咨询师', avatar: '💝', role: '情感导师' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F]">
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-32 pb-20 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="glass rounded-3xl p-8 mb-6">
|
||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-4xl">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.nickName} className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
'👤'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<h1 className="text-3xl font-bold mb-2">{user.nickName || user.username}</h1>
|
||||
<p className="text-white/60 mb-4">{user.email || '未设置邮箱'}</p>
|
||||
<div className="flex flex-wrap gap-6 justify-center md:justify-start">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary">{user.chatCount}</div>
|
||||
<div className="text-sm text-white/60">总对话</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-secondary">{user.messageCount}</div>
|
||||
<div className="text-sm text-white/60">消息数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-cta">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '从未'}
|
||||
</div>
|
||||
<div className="text-sm text-white/60">最后登录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-6 py-3 glass-hover rounded-xl cursor-pointer flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
编辑资料
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-2 mb-6 inline-flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`px-6 py-2 rounded-lg text-sm cursor-pointer transition-all ${
|
||||
activeTab === 'overview' ? 'bg-gradient-to-r from-primary to-secondary' : 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
概览
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('favorites')}
|
||||
className={`px-6 py-2 rounded-lg text-sm cursor-pointer transition-all ${
|
||||
activeTab === 'favorites' ? 'bg-gradient-to-r from-primary to-secondary' : 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
收藏
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`px-6 py-2 rounded-lg text-sm cursor-pointer transition-all ${
|
||||
activeTab === 'settings' ? 'bg-gradient-to-r from-primary to-secondary' : 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
最近对话
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{recentChats.map((chat) => (
|
||||
<Link
|
||||
key={chat.id}
|
||||
to="/chat"
|
||||
className="glass-hover rounded-xl p-4 block cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">{chat.character}</span>
|
||||
<span className="text-xs text-white/40">{chat.time}</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 truncate">{chat.lastMessage}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-400" />
|
||||
成就徽章
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{['🏆', '⭐', '🎯', '💎', '🔥', '👑'].map((badge, i) => (
|
||||
<div key={i} className="glass-hover rounded-xl p-4 text-center cursor-pointer">
|
||||
<div className="text-3xl mb-2">{badge}</div>
|
||||
<div className="text-xs text-white/60">成就 {i + 1}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'favorites' && (
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Heart className="w-5 h-5 text-red-400" />
|
||||
收藏的角色
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
{favoriteCharacters.map((char) => (
|
||||
<Link
|
||||
key={char.id}
|
||||
to={`/character/${char.id}`}
|
||||
className="glass-hover rounded-xl p-6 text-center cursor-pointer"
|
||||
>
|
||||
<div className="text-5xl mb-3">{char.avatar}</div>
|
||||
<h3 className="font-semibold mb-1">{char.name}</h3>
|
||||
<p className="text-sm text-white/60">{char.role}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="space-y-6">
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
账号设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={user.username}
|
||||
disabled
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
defaultValue={user.email}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">昵称</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={user.nickName}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-secondary" />
|
||||
隐私与安全
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">公开我的收藏</span>
|
||||
<input type="checkbox" defaultChecked className="cursor-pointer" />
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">允许其他用户关注我</span>
|
||||
<input type="checkbox" defaultChecked className="cursor-pointer" />
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">显示在线状态</span>
|
||||
<input type="checkbox" className="cursor-pointer" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-cta" />
|
||||
通知设置
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">新消息通知</span>
|
||||
<input type="checkbox" defaultChecked className="cursor-pointer" />
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">新粉丝通知</span>
|
||||
<input type="checkbox" defaultChecked className="cursor-pointer" />
|
||||
</label>
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm">系统更新通知</span>
|
||||
<input type="checkbox" className="cursor-pointer" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer">
|
||||
保存更改
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-6 py-3 glass-hover rounded-xl font-medium cursor-pointer flex items-center gap-2 text-red-400"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
web-app/src/pages/RegisterPage.tsx
Normal file
184
web-app/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import {useState} from 'react'
|
||||
import {Link, useNavigate} from 'react-router-dom'
|
||||
import {Eye, EyeOff, Lock, Mail, Sparkles, User} from 'lucide-react'
|
||||
import {authApi} from '../api/auth'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
nickName: ''
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('密码长度至少为 6 位')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await authApi.register({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
nickName: formData.nickName || formData.username,
|
||||
})
|
||||
|
||||
alert('注册成功!请登录')
|
||||
navigate('/login')
|
||||
} catch (err: any) {
|
||||
setError(err || '注册失败,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#0A0A0F] via-[#1A0B2E] to-[#0A0A0F] flex items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<Link to="/" className="flex items-center justify-center gap-2 mb-8">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
云酒馆
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="glass rounded-3xl p-8">
|
||||
<h1 className="text-2xl font-bold mb-2 text-center">创建账号</h1>
|
||||
<p className="text-white/60 text-center mb-8">开始你的 AI 对话之旅</p>
|
||||
|
||||
{error && (
|
||||
<div className="glass rounded-xl p-4 border border-red-500/50 bg-red-500/10 mb-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">用户名</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="选择一个用户名"
|
||||
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">邮箱</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
className="w-full pl-12 pr-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="至少 6 个字符"
|
||||
className="w-full pl-12 pr-12 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60 cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">确认密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40" />
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
placeholder="再次输入密码"
|
||||
className="w-full pl-12 pr-12 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60 cursor-pointer"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-white/60 cursor-pointer">
|
||||
<input type="checkbox" className="mt-1 cursor-pointer" required />
|
||||
<span>
|
||||
我同意
|
||||
<Link to="/terms" className="text-primary hover:text-secondary transition-colors mx-1">
|
||||
服务条款
|
||||
</Link>
|
||||
和
|
||||
<Link to="/privacy" className="text-primary hover:text-secondary transition-colors ml-1">
|
||||
隐私政策
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-white/60">
|
||||
已有账号?
|
||||
<Link to="/login" className="text-primary hover:text-secondary transition-colors ml-1 cursor-pointer">
|
||||
立即登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
web-app/tailwind.config.js
Normal file
20
web-app/tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: 'var(--color-primary)',
|
||||
secondary: 'var(--color-secondary)',
|
||||
cta: '#F97316',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
web-app/tsconfig.json
Normal file
21
web-app/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
web-app/tsconfig.node.json
Normal file
10
web-app/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
6
web-app/vite.config.ts
Normal file
6
web-app/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user