移除gitignore中的lib文件夹,上传被排除掉的前端lib文件
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,7 +91,6 @@ develop-eggs/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
|
|||||||
95
dashboard/src/lib/adapter-config-api.ts
Normal file
95
dashboard/src/lib/adapter-config-api.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 适配器配置API客户端
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/config'
|
||||||
|
|
||||||
|
export interface AdapterConfigPath {
|
||||||
|
path: string
|
||||||
|
lastModified?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigPathResponse {
|
||||||
|
success: boolean
|
||||||
|
path?: string
|
||||||
|
lastModified?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigContentResponse {
|
||||||
|
success: boolean
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigMessageResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取保存的适配器配置文件路径
|
||||||
|
*/
|
||||||
|
export async function getSavedConfigPath(): Promise<AdapterConfigPath | null> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`)
|
||||||
|
const data: ConfigPathResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success || !data.path) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: data.path,
|
||||||
|
lastModified: data.lastModified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存适配器配置文件路径偏好设置
|
||||||
|
*/
|
||||||
|
export async function saveConfigPath(path: string): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigMessageResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存路径失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定路径读取适配器配置文件
|
||||||
|
*/
|
||||||
|
export async function loadConfigFromPath(path: string): Promise<string> {
|
||||||
|
const response = await fetchWithAuth(
|
||||||
|
`${API_BASE}/adapter-config?path=${encodeURIComponent(path)}`
|
||||||
|
)
|
||||||
|
const data: ConfigContentResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('读取配置文件失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存适配器配置到指定路径
|
||||||
|
*/
|
||||||
|
export async function saveConfigToPath(path: string, content: string): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/adapter-config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ path, content }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigMessageResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
10
dashboard/src/lib/animation-context.ts
Normal file
10
dashboard/src/lib/animation-context.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
export type AnimationSettings = {
|
||||||
|
enableAnimations: boolean
|
||||||
|
enableWavesBackground: boolean
|
||||||
|
setEnableAnimations: (enable: boolean) => void
|
||||||
|
setEnableWavesBackground: (enable: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimationContext = createContext<AnimationSettings | undefined>(undefined)
|
||||||
136
dashboard/src/lib/annual-report-api.ts
Normal file
136
dashboard/src/lib/annual-report-api.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { fetchWithAuth } from './fetch-with-auth'
|
||||||
|
|
||||||
|
export interface TimeFootprintData {
|
||||||
|
total_online_hours: number
|
||||||
|
first_message_time: string | null
|
||||||
|
first_message_user: string | null
|
||||||
|
first_message_content: string | null
|
||||||
|
busiest_day: string | null
|
||||||
|
busiest_day_count: number
|
||||||
|
hourly_distribution: number[]
|
||||||
|
midnight_chat_count: number
|
||||||
|
is_night_owl: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialNetworkData {
|
||||||
|
total_groups: number
|
||||||
|
top_groups: Array<{
|
||||||
|
group_id: string
|
||||||
|
group_name: string
|
||||||
|
message_count: number
|
||||||
|
is_webui?: boolean
|
||||||
|
}>
|
||||||
|
top_users: Array<{
|
||||||
|
user_id: string
|
||||||
|
user_nickname: string
|
||||||
|
message_count: number
|
||||||
|
is_webui?: boolean
|
||||||
|
}>
|
||||||
|
at_count: number
|
||||||
|
mentioned_count: number
|
||||||
|
longest_companion_user: string | null
|
||||||
|
longest_companion_days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrainPowerData {
|
||||||
|
total_tokens: number
|
||||||
|
total_cost: number
|
||||||
|
favorite_model: string | null
|
||||||
|
favorite_model_count: number
|
||||||
|
model_distribution: Array<{
|
||||||
|
model: string
|
||||||
|
count: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
}>
|
||||||
|
top_reply_models: Array<{
|
||||||
|
model: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
most_expensive_cost: number
|
||||||
|
most_expensive_time: string | null
|
||||||
|
top_token_consumers: Array<{
|
||||||
|
user_id: string
|
||||||
|
cost: number
|
||||||
|
tokens: number
|
||||||
|
}>
|
||||||
|
silence_rate: number
|
||||||
|
total_actions: number
|
||||||
|
no_reply_count: number
|
||||||
|
avg_interest_value: number
|
||||||
|
max_interest_value: number
|
||||||
|
max_interest_time: string | null
|
||||||
|
avg_reasoning_length: number
|
||||||
|
max_reasoning_length: number
|
||||||
|
max_reasoning_time: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionVibeData {
|
||||||
|
top_emoji: {
|
||||||
|
id: number
|
||||||
|
path: string
|
||||||
|
description: string
|
||||||
|
usage_count: number
|
||||||
|
hash: string
|
||||||
|
} | null
|
||||||
|
top_emojis: Array<{
|
||||||
|
id: number
|
||||||
|
path: string
|
||||||
|
description: string
|
||||||
|
usage_count: number
|
||||||
|
hash: string
|
||||||
|
}>
|
||||||
|
top_expressions: Array<{
|
||||||
|
style: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
rejected_expression_count: number
|
||||||
|
checked_expression_count: number
|
||||||
|
total_expressions: number
|
||||||
|
action_types: Array<{
|
||||||
|
action: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
image_processed_count: number
|
||||||
|
late_night_reply: {
|
||||||
|
time: string
|
||||||
|
content: string
|
||||||
|
} | null
|
||||||
|
favorite_reply: {
|
||||||
|
content: string
|
||||||
|
count: number
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementData {
|
||||||
|
new_jargon_count: number
|
||||||
|
sample_jargons: Array<{
|
||||||
|
content: string
|
||||||
|
meaning: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
total_messages: number
|
||||||
|
total_replies: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnualReportData {
|
||||||
|
year: number
|
||||||
|
bot_name: string
|
||||||
|
generated_at: string
|
||||||
|
time_footprint: TimeFootprintData
|
||||||
|
social_network: SocialNetworkData
|
||||||
|
brain_power: BrainPowerData
|
||||||
|
expression_vibe: ExpressionVibeData
|
||||||
|
achievements: AchievementData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnnualReport(year: number = 2025): Promise<AnnualReportData> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/annual-report/full?year=${year}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取年度报告失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
8
dashboard/src/lib/api.ts
Normal file
8
dashboard/src/lib/api.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.DEV ? 'http://localhost:8000' : '',
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
269
dashboard/src/lib/config-api.ts
Normal file
269
dashboard/src/lib/config-api.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* 配置API客户端
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
ConfigSchema,
|
||||||
|
ConfigSchemaResponse,
|
||||||
|
ConfigDataResponse,
|
||||||
|
ConfigUpdateResponse,
|
||||||
|
} from '@/types/config-schema'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取麦麦主程序配置架构
|
||||||
|
*/
|
||||||
|
export async function getBotConfigSchema(): Promise<ConfigSchema> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/schema/bot`)
|
||||||
|
const data: ConfigSchemaResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取配置架构失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型配置架构
|
||||||
|
*/
|
||||||
|
export async function getModelConfigSchema(): Promise<ConfigSchema> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/schema/model`)
|
||||||
|
const data: ConfigSchemaResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取模型配置架构失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定配置节的架构
|
||||||
|
*/
|
||||||
|
export async function getConfigSectionSchema(sectionName: string): Promise<ConfigSchema> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`)
|
||||||
|
const data: ConfigSchemaResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(`获取配置节 ${sectionName} 架构失败`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取麦麦主程序配置数据
|
||||||
|
*/
|
||||||
|
export async function getBotConfig(): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot`)
|
||||||
|
const data: ConfigDataResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取配置数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型配置数据
|
||||||
|
*/
|
||||||
|
export async function getModelConfig(): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/model`)
|
||||||
|
const data: ConfigDataResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取模型配置数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新麦麦主程序配置
|
||||||
|
*/
|
||||||
|
export async function updateBotConfig(config: Record<string, unknown>): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取麦麦主程序配置的原始 TOML 内容
|
||||||
|
*/
|
||||||
|
export async function getBotConfigRaw(): Promise<string> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot/raw`)
|
||||||
|
const data: { success: boolean; content: string } = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取配置源代码失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新麦麦主程序配置(原始 TOML 内容)
|
||||||
|
*/
|
||||||
|
export async function updateBotConfigRaw(rawContent: string): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot/raw`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ raw_content: rawContent }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模型配置
|
||||||
|
*/
|
||||||
|
export async function updateModelConfig(config: Record<string, unknown>): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/model`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新麦麦主程序配置的指定节
|
||||||
|
*/
|
||||||
|
export async function updateBotConfigSection(
|
||||||
|
sectionName: string,
|
||||||
|
sectionData: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot/section/${sectionName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(sectionData),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || `保存配置节 ${sectionName} 失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模型配置的指定节
|
||||||
|
*/
|
||||||
|
export async function updateModelConfigSection(
|
||||||
|
sectionName: string,
|
||||||
|
sectionData: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/model/section/${sectionName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(sectionData),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || `保存配置节 ${sectionName} 失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型信息
|
||||||
|
*/
|
||||||
|
export interface ModelListItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
owned_by?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型列表响应
|
||||||
|
*/
|
||||||
|
export interface FetchModelsResponse {
|
||||||
|
success: boolean
|
||||||
|
models: ModelListItem[]
|
||||||
|
provider?: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定提供商的可用模型列表
|
||||||
|
* @param providerName 提供商名称(在 model_config.toml 中配置的名称)
|
||||||
|
* @param parser 响应解析器类型 ('openai' | 'gemini')
|
||||||
|
* @param endpoint 获取模型列表的端点(默认 '/models')
|
||||||
|
*/
|
||||||
|
export async function fetchProviderModels(
|
||||||
|
providerName: string,
|
||||||
|
parser: 'openai' | 'gemini' = 'openai',
|
||||||
|
endpoint: string = '/models'
|
||||||
|
): Promise<ModelListItem[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
provider_name: providerName,
|
||||||
|
parser,
|
||||||
|
endpoint,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`/api/webui/models/list?${params}`)
|
||||||
|
|
||||||
|
// 处理非 2xx 响应
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.detail || `获取模型列表失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: FetchModelsResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取模型列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.models
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试提供商连接结果
|
||||||
|
*/
|
||||||
|
export interface TestConnectionResult {
|
||||||
|
network_ok: boolean
|
||||||
|
api_key_valid: boolean | null
|
||||||
|
latency_ms: number | null
|
||||||
|
error: string | null
|
||||||
|
http_status: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试提供商连接状态(通过提供商名称)
|
||||||
|
* @param providerName 提供商名称
|
||||||
|
*/
|
||||||
|
export async function testProviderConnection(providerName: string): Promise<TestConnectionResult> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
provider_name: providerName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`/api/webui/models/test-connection-by-name?${params}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理非 2xx 响应
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.detail || `测试连接失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
284
dashboard/src/lib/emoji-api.ts
Normal file
284
dashboard/src/lib/emoji-api.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* 表情包管理 API 客户端
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
EmojiListResponse,
|
||||||
|
EmojiDetailResponse,
|
||||||
|
EmojiUpdateRequest,
|
||||||
|
EmojiUpdateResponse,
|
||||||
|
EmojiDeleteResponse,
|
||||||
|
EmojiStatsResponse,
|
||||||
|
} from '@/types/emoji'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/emoji'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包列表
|
||||||
|
*/
|
||||||
|
export async function getEmojiList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
is_registered?: boolean
|
||||||
|
is_banned?: boolean
|
||||||
|
format?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
}): Promise<EmojiListResponse> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params.page) query.append('page', params.page.toString())
|
||||||
|
if (params.page_size) query.append('page_size', params.page_size.toString())
|
||||||
|
if (params.search) query.append('search', params.search)
|
||||||
|
if (params.is_registered !== undefined) query.append('is_registered', params.is_registered.toString())
|
||||||
|
if (params.is_banned !== undefined) query.append('is_banned', params.is_banned.toString())
|
||||||
|
if (params.format) query.append('format', params.format)
|
||||||
|
if (params.sort_by) query.append('sort_by', params.sort_by)
|
||||||
|
if (params.sort_order) query.append('sort_order', params.sort_order)
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/list?${query}`, {
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取表情包列表失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包详情
|
||||||
|
*/
|
||||||
|
export async function getEmojiDetail(id: number): Promise<EmojiDetailResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}`, {
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取表情包详情失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新表情包信息
|
||||||
|
*/
|
||||||
|
export async function updateEmoji(
|
||||||
|
id: number,
|
||||||
|
data: EmojiUpdateRequest
|
||||||
|
): Promise<EmojiUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`更新表情包失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除表情包
|
||||||
|
*/
|
||||||
|
export async function deleteEmoji(id: number): Promise<EmojiDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`删除表情包失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包统计数据
|
||||||
|
*/
|
||||||
|
export async function getEmojiStats(): Promise<EmojiStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取统计数据失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册表情包
|
||||||
|
*/
|
||||||
|
export async function registerEmoji(id: number): Promise<EmojiUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`注册表情包失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封禁表情包
|
||||||
|
*/
|
||||||
|
export async function banEmoji(id: number): Promise<EmojiUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}/ban`, {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`封禁表情包失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包缩略图 URL
|
||||||
|
* 注意:使用 HttpOnly Cookie 进行认证,浏览器会自动携带
|
||||||
|
* @param id 表情包 ID
|
||||||
|
* @param original 是否获取原图(默认返回压缩后的缩略图)
|
||||||
|
*/
|
||||||
|
export function getEmojiThumbnailUrl(id: number, original: boolean = false): string {
|
||||||
|
if (original) {
|
||||||
|
return `${API_BASE}/${id}/thumbnail?original=true`
|
||||||
|
}
|
||||||
|
return `${API_BASE}/${id}/thumbnail`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包原图 URL
|
||||||
|
*/
|
||||||
|
export function getEmojiOriginalUrl(id: number): string {
|
||||||
|
return `${API_BASE}/${id}/thumbnail?original=true`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除表情包
|
||||||
|
*/
|
||||||
|
export async function batchDeleteEmojis(emojiIds: number[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
deleted_count: number
|
||||||
|
failed_count: number
|
||||||
|
failed_ids: number[]
|
||||||
|
}> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({ emoji_ids: emojiIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量删除失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包上传 URL(供 Uppy 使用)
|
||||||
|
*/
|
||||||
|
export function getEmojiUploadUrl(): string {
|
||||||
|
return `${API_BASE}/upload`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取批量上传 URL
|
||||||
|
*/
|
||||||
|
export function getEmojiBatchUploadUrl(): string {
|
||||||
|
return `${API_BASE}/batch/upload`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 缩略图缓存管理 API ====================
|
||||||
|
|
||||||
|
export interface ThumbnailCacheStatsResponse {
|
||||||
|
success: boolean
|
||||||
|
cache_dir: string
|
||||||
|
total_count: number
|
||||||
|
total_size_mb: number
|
||||||
|
emoji_count: number
|
||||||
|
coverage_percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThumbnailCleanupResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
cleaned_count: number
|
||||||
|
kept_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThumbnailPreheatResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
generated_count: number
|
||||||
|
skipped_count: number
|
||||||
|
failed_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缩略图缓存统计信息
|
||||||
|
*/
|
||||||
|
export async function getThumbnailCacheStats(): Promise<ThumbnailCacheStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/stats`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取缩略图缓存统计失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理孤立的缩略图缓存
|
||||||
|
*/
|
||||||
|
export async function cleanupThumbnailCache(): Promise<ThumbnailCleanupResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/cleanup`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`清理缩略图缓存失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预热缩略图缓存
|
||||||
|
* @param limit 最多预热数量 (1-1000)
|
||||||
|
*/
|
||||||
|
export async function preheatThumbnailCache(limit: number = 100): Promise<ThumbnailPreheatResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/preheat?limit=${limit}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`预热缩略图缓存失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有缩略图缓存
|
||||||
|
*/
|
||||||
|
export async function clearAllThumbnailCache(): Promise<ThumbnailCleanupResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/clear`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`清空缩略图缓存失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
236
dashboard/src/lib/expression-api.ts
Normal file
236
dashboard/src/lib/expression-api.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* 表达方式管理 API
|
||||||
|
*/
|
||||||
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
ExpressionListResponse,
|
||||||
|
ExpressionDetailResponse,
|
||||||
|
ExpressionCreateRequest,
|
||||||
|
ExpressionCreateResponse,
|
||||||
|
ExpressionUpdateRequest,
|
||||||
|
ExpressionUpdateResponse,
|
||||||
|
ExpressionDeleteResponse,
|
||||||
|
ExpressionStatsResponse,
|
||||||
|
ChatListResponse,
|
||||||
|
ReviewStats,
|
||||||
|
ReviewListResponse,
|
||||||
|
BatchReviewItem,
|
||||||
|
BatchReviewResponse,
|
||||||
|
} from '@/types/expression'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/expression'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天列表
|
||||||
|
*/
|
||||||
|
export async function getChatList(): Promise<ChatListResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/chats`, {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取聊天列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表达方式列表
|
||||||
|
*/
|
||||||
|
export async function getExpressionList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
chat_id?: string
|
||||||
|
}): Promise<ExpressionListResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString())
|
||||||
|
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||||
|
if (params.search) queryParams.append('search', params.search)
|
||||||
|
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取表达方式列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表达方式详细信息
|
||||||
|
*/
|
||||||
|
export async function getExpressionDetail(expressionId: number): Promise<ExpressionDetailResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取表达方式详情失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建表达方式
|
||||||
|
*/
|
||||||
|
export async function createExpression(
|
||||||
|
data: ExpressionCreateRequest
|
||||||
|
): Promise<ExpressionCreateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '创建表达方式失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新表达方式(增量更新)
|
||||||
|
*/
|
||||||
|
export async function updateExpression(
|
||||||
|
expressionId: number,
|
||||||
|
data: ExpressionUpdateRequest
|
||||||
|
): Promise<ExpressionUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '更新表达方式失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除表达方式
|
||||||
|
*/
|
||||||
|
export async function deleteExpression(expressionId: number): Promise<ExpressionDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '删除表达方式失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除表达方式
|
||||||
|
*/
|
||||||
|
export async function batchDeleteExpressions(expressionIds: number[]): Promise<ExpressionDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({ ids: expressionIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量删除表达方式失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表达方式统计数据
|
||||||
|
*/
|
||||||
|
export async function getExpressionStats(): Promise<ExpressionStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 审核相关 API ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审核统计数据
|
||||||
|
*/
|
||||||
|
export async function getReviewStats(): Promise<ReviewStats> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/review/stats`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取审核统计失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审核列表
|
||||||
|
*/
|
||||||
|
export async function getReviewList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
filter_type?: 'unchecked' | 'passed' | 'rejected' | 'all'
|
||||||
|
search?: string
|
||||||
|
chat_id?: string
|
||||||
|
}): Promise<ReviewListResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString())
|
||||||
|
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||||
|
if (params.filter_type) queryParams.append('filter_type', params.filter_type)
|
||||||
|
if (params.search) queryParams.append('search', params.search)
|
||||||
|
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取审核列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量审核表达方式
|
||||||
|
*/
|
||||||
|
export async function batchReviewExpressions(
|
||||||
|
items: BatchReviewItem[]
|
||||||
|
): Promise<BatchReviewResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/review/batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量审核失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
82
dashboard/src/lib/fetch-with-auth.ts
Normal file
82
dashboard/src/lib/fetch-with-auth.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// 带自动认证处理的 fetch 封装
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增强的 fetch 函数,自动处理 401 错误并跳转到登录页
|
||||||
|
* 使用 HttpOnly Cookie 进行认证,自动携带 credentials
|
||||||
|
*
|
||||||
|
* 对于 FormData 请求,不自动设置 Content-Type,让浏览器自动设置 multipart/form-data
|
||||||
|
*/
|
||||||
|
export async function fetchWithAuth(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<Response> {
|
||||||
|
// 检查是否是 FormData 请求
|
||||||
|
const isFormData = init?.body instanceof FormData
|
||||||
|
|
||||||
|
// 构建 headers,对于 FormData 不设置 Content-Type
|
||||||
|
const headers: HeadersInit = isFormData
|
||||||
|
? { ...init?.headers }
|
||||||
|
: { 'Content-Type': 'application/json', ...init?.headers }
|
||||||
|
|
||||||
|
// 合并默认配置,确保携带 Cookie
|
||||||
|
const config: RequestInit = {
|
||||||
|
...init,
|
||||||
|
credentials: 'include', // 确保携带 Cookie
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(input, config)
|
||||||
|
|
||||||
|
// 检测 401 未授权错误
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 跳转到登录页
|
||||||
|
window.location.href = '/auth'
|
||||||
|
|
||||||
|
// 抛出错误以便调用者可以处理
|
||||||
|
throw new Error('认证失败,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取带认证的请求配置
|
||||||
|
* 现在使用 Cookie 认证,不再需要手动设置 Authorization header
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(): HeadersInit {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用登出接口并跳转到登录页
|
||||||
|
*/
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch('/api/webui/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登出请求失败:', error)
|
||||||
|
}
|
||||||
|
// 无论成功与否都跳转到登录页
|
||||||
|
window.location.href = '/auth'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前认证状态
|
||||||
|
*/
|
||||||
|
export async function checkAuthStatus(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/webui/auth/check', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
return data.authenticated === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
188
dashboard/src/lib/jargon-api.ts
Normal file
188
dashboard/src/lib/jargon-api.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* 黑话(俚语)管理 API
|
||||||
|
*/
|
||||||
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
JargonListResponse,
|
||||||
|
JargonDetailResponse,
|
||||||
|
JargonCreateRequest,
|
||||||
|
JargonCreateResponse,
|
||||||
|
JargonUpdateRequest,
|
||||||
|
JargonUpdateResponse,
|
||||||
|
JargonDeleteResponse,
|
||||||
|
JargonStatsResponse,
|
||||||
|
JargonChatListResponse,
|
||||||
|
} from '@/types/jargon'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/jargon'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天列表(有黑话记录的聊天)
|
||||||
|
*/
|
||||||
|
export async function getJargonChatList(): Promise<JargonChatListResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/chats`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取聊天列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取黑话列表
|
||||||
|
*/
|
||||||
|
export async function getJargonList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
chat_id?: string
|
||||||
|
is_jargon?: boolean | null
|
||||||
|
is_global?: boolean
|
||||||
|
}): Promise<JargonListResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString())
|
||||||
|
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||||
|
if (params.search) queryParams.append('search', params.search)
|
||||||
|
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||||
|
if (params.is_jargon !== undefined && params.is_jargon !== null) {
|
||||||
|
queryParams.append('is_jargon', params.is_jargon.toString())
|
||||||
|
}
|
||||||
|
if (params.is_global !== undefined) {
|
||||||
|
queryParams.append('is_global', params.is_global.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取黑话列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取黑话详细信息
|
||||||
|
*/
|
||||||
|
export async function getJargonDetail(jargonId: number): Promise<JargonDetailResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取黑话详情失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建黑话
|
||||||
|
*/
|
||||||
|
export async function createJargon(
|
||||||
|
data: JargonCreateRequest
|
||||||
|
): Promise<JargonCreateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '创建黑话失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新黑话(增量更新)
|
||||||
|
*/
|
||||||
|
export async function updateJargon(
|
||||||
|
jargonId: number,
|
||||||
|
data: JargonUpdateRequest
|
||||||
|
): Promise<JargonUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '更新黑话失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除黑话
|
||||||
|
*/
|
||||||
|
export async function deleteJargon(jargonId: number): Promise<JargonDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '删除黑话失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除黑话
|
||||||
|
*/
|
||||||
|
export async function batchDeleteJargons(jargonIds: number[]): Promise<JargonDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: jargonIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量删除黑话失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取黑话统计数据
|
||||||
|
*/
|
||||||
|
export async function getJargonStats(): Promise<JargonStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取黑话统计失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置黑话状态
|
||||||
|
*/
|
||||||
|
export async function batchSetJargonStatus(
|
||||||
|
jargonIds: number[],
|
||||||
|
isJargon: boolean
|
||||||
|
): Promise<JargonUpdateResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
jargonIds.forEach(id => queryParams.append('ids', id.toString()))
|
||||||
|
queryParams.append('is_jargon', isJargon.toString())
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/set-jargon?${queryParams}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量设置黑话状态失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
69
dashboard/src/lib/knowledge-api.ts
Normal file
69
dashboard/src/lib/knowledge-api.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 知识库 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/webui'
|
||||||
|
|
||||||
|
export interface KnowledgeNode {
|
||||||
|
id: string
|
||||||
|
type: 'entity' | 'paragraph'
|
||||||
|
content: string
|
||||||
|
create_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
weight: number
|
||||||
|
create_time?: number
|
||||||
|
update_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeGraph {
|
||||||
|
nodes: KnowledgeNode[]
|
||||||
|
edges: KnowledgeEdge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeStats {
|
||||||
|
total_nodes: number
|
||||||
|
total_edges: number
|
||||||
|
entity_nodes: number
|
||||||
|
paragraph_nodes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识图谱数据
|
||||||
|
*/
|
||||||
|
export async function getKnowledgeGraph(limit: number = 100, nodeType: 'all' | 'entity' | 'paragraph' = 'all'): Promise<KnowledgeGraph> {
|
||||||
|
const url = `${API_BASE_URL}/knowledge/graph?limit=${limit}&node_type=${nodeType}`
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取知识图谱失败: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识图谱统计信息
|
||||||
|
*/
|
||||||
|
export async function getKnowledgeStats(): Promise<KnowledgeStats> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/knowledge/stats`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取知识图谱统计信息失败')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索知识节点
|
||||||
|
*/
|
||||||
|
export async function searchKnowledgeNode(query: string): Promise<KnowledgeNode[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/knowledge/search?query=${encodeURIComponent(query)}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('搜索知识节点失败')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
0
dashboard/src/lib/log-stream.ts
Normal file
0
dashboard/src/lib/log-stream.ts
Normal file
326
dashboard/src/lib/log-websocket.ts
Normal file
326
dashboard/src/lib/log-websocket.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* 全局日志 WebSocket 管理器
|
||||||
|
* 确保整个应用只有一个 WebSocket 连接
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth, checkAuthStatus } from './fetch-with-auth'
|
||||||
|
import { getSetting } from './settings-manager'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: string
|
||||||
|
timestamp: string
|
||||||
|
level: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'
|
||||||
|
module: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogCallback = (log: LogEntry) => void
|
||||||
|
type ConnectionCallback = (connected: boolean) => void
|
||||||
|
|
||||||
|
class LogWebSocketManager {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private reconnectTimeout: number | null = null
|
||||||
|
private reconnectAttempts = 0
|
||||||
|
private heartbeatInterval: number | null = null
|
||||||
|
|
||||||
|
// 订阅者
|
||||||
|
private logCallbacks: Set<LogCallback> = new Set()
|
||||||
|
private connectionCallbacks: Set<ConnectionCallback> = new Set()
|
||||||
|
|
||||||
|
private isConnected = false
|
||||||
|
|
||||||
|
// 日志缓存 - 保存所有接收到的日志
|
||||||
|
private logCache: LogEntry[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最大缓存大小(从设置读取)
|
||||||
|
*/
|
||||||
|
private getMaxCacheSize(): number {
|
||||||
|
return getSetting('logCacheSize')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最大重连次数(从设置读取)
|
||||||
|
*/
|
||||||
|
private getMaxReconnectAttempts(): number {
|
||||||
|
return getSetting('wsMaxReconnectAttempts')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取重连间隔(从设置读取)
|
||||||
|
*/
|
||||||
|
private getReconnectInterval(): number {
|
||||||
|
return getSetting('wsReconnectInterval')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocket URL
|
||||||
|
*/
|
||||||
|
private getWebSocketUrl(token?: string): string {
|
||||||
|
let baseUrl: string
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// 开发模式:连接到 WebUI 后端服务器
|
||||||
|
baseUrl = 'ws://127.0.0.1:8001/ws/logs'
|
||||||
|
} else {
|
||||||
|
// 生产模式:使用当前页面的 host
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = window.location.host
|
||||||
|
baseUrl = `${protocol}//${host}/ws/logs`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 token,添加到 URL 参数
|
||||||
|
if (token) {
|
||||||
|
return `${baseUrl}?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
return baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocket 临时认证 token
|
||||||
|
*/
|
||||||
|
private async getWsToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// 使用相对路径,让前端代理处理请求,避免 CORS 问题
|
||||||
|
const response = await fetchWithAuth('/api/webui/ws-token', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include', // 携带 Cookie
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('获取 WebSocket token 失败:', response.status)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.token) {
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 WebSocket token 失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接 WebSocket(会先检查登录状态)
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否在登录页面
|
||||||
|
if (window.location.pathname === '/auth') {
|
||||||
|
console.log('📡 在登录页面,跳过 WebSocket 连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查登录状态,避免未登录时尝试连接
|
||||||
|
const isAuthenticated = await checkAuthStatus()
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
console.log('📡 未登录,跳过 WebSocket 连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先获取临时认证 token
|
||||||
|
const wsToken = await this.getWsToken()
|
||||||
|
if (!wsToken) {
|
||||||
|
console.log('📡 无法获取 WebSocket token,跳过连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = this.getWebSocketUrl(wsToken)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.isConnected = true
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
this.notifyConnection(true)
|
||||||
|
this.startHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
// 忽略心跳响应
|
||||||
|
if (event.data === 'pong') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const log: LogEntry = JSON.parse(event.data)
|
||||||
|
this.notifyLog(log)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析日志消息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('❌ WebSocket 错误:', error)
|
||||||
|
this.isConnected = false
|
||||||
|
this.notifyConnection(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.isConnected = false
|
||||||
|
this.notifyConnection(false)
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.attemptReconnect()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建 WebSocket 连接失败:', error)
|
||||||
|
this.attemptReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试重连
|
||||||
|
*/
|
||||||
|
private attemptReconnect() {
|
||||||
|
const maxAttempts = this.getMaxReconnectAttempts()
|
||||||
|
if (this.reconnectAttempts >= maxAttempts) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts += 1
|
||||||
|
const baseInterval = this.getReconnectInterval()
|
||||||
|
const delay = Math.min(baseInterval * this.reconnectAttempts, 30000)
|
||||||
|
|
||||||
|
this.reconnectTimeout = window.setTimeout(() => {
|
||||||
|
this.connect() // connect 是 async 但这里不需要 await,它内部会处理错误
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动心跳
|
||||||
|
*/
|
||||||
|
private startHeartbeat() {
|
||||||
|
this.heartbeatInterval = window.setInterval(() => {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send('ping')
|
||||||
|
}
|
||||||
|
}, 30000) // 每30秒发送一次心跳
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止心跳
|
||||||
|
*/
|
||||||
|
private stopHeartbeat() {
|
||||||
|
if (this.heartbeatInterval !== null) {
|
||||||
|
clearInterval(this.heartbeatInterval)
|
||||||
|
this.heartbeatInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开连接
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
if (this.reconnectTimeout !== null) {
|
||||||
|
clearTimeout(this.reconnectTimeout)
|
||||||
|
this.reconnectTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopHeartbeat()
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close()
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = false
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅日志消息
|
||||||
|
*/
|
||||||
|
onLog(callback: LogCallback) {
|
||||||
|
this.logCallbacks.add(callback)
|
||||||
|
return () => this.logCallbacks.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅连接状态
|
||||||
|
*/
|
||||||
|
onConnectionChange(callback: ConnectionCallback) {
|
||||||
|
this.connectionCallbacks.add(callback)
|
||||||
|
// 立即通知当前状态
|
||||||
|
callback(this.isConnected)
|
||||||
|
return () => this.connectionCallbacks.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知所有订阅者新日志
|
||||||
|
*/
|
||||||
|
private notifyLog(log: LogEntry) {
|
||||||
|
// 检查是否已存在(通过 id 去重)
|
||||||
|
const exists = this.logCache.some(existingLog => existingLog.id === log.id)
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
// 添加到缓存
|
||||||
|
this.logCache.push(log)
|
||||||
|
|
||||||
|
// 限制缓存大小(动态读取配置)
|
||||||
|
const maxCacheSize = this.getMaxCacheSize()
|
||||||
|
if (this.logCache.length > maxCacheSize) {
|
||||||
|
this.logCache = this.logCache.slice(-maxCacheSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有新日志才通知订阅者
|
||||||
|
this.logCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(log)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('日志回调执行失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知所有订阅者连接状态变化
|
||||||
|
*/
|
||||||
|
private notifyConnection(connected: boolean) {
|
||||||
|
this.connectionCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(connected)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('连接状态回调执行失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的所有日志
|
||||||
|
*/
|
||||||
|
getAllLogs(): LogEntry[] {
|
||||||
|
return [...this.logCache]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空日志缓存
|
||||||
|
*/
|
||||||
|
clearLogs() {
|
||||||
|
this.logCache = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前连接状态
|
||||||
|
*/
|
||||||
|
getConnectionStatus(): boolean {
|
||||||
|
return this.isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const logWebSocket = new LogWebSocketManager()
|
||||||
|
|
||||||
|
// 自动连接(应用启动时)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// 延迟一下确保页面加载完成
|
||||||
|
setTimeout(() => {
|
||||||
|
logWebSocket.connect()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
570
dashboard/src/lib/pack-api.ts
Normal file
570
dashboard/src/lib/pack-api.ts
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
/**
|
||||||
|
* 模型配置 Pack API
|
||||||
|
*
|
||||||
|
* 与 Cloudflare Workers Pack 服务交互
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from './fetch-with-auth'
|
||||||
|
|
||||||
|
// ============ 类型定义 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供商配置(分享时不含 api_key)
|
||||||
|
*/
|
||||||
|
export interface PackProvider {
|
||||||
|
name: string
|
||||||
|
base_url: string
|
||||||
|
client_type: 'openai' | 'gemini'
|
||||||
|
max_retry?: number
|
||||||
|
timeout?: number
|
||||||
|
retry_interval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型配置
|
||||||
|
*/
|
||||||
|
export interface PackModel {
|
||||||
|
model_identifier: string
|
||||||
|
name: string
|
||||||
|
api_provider: string
|
||||||
|
price_in: number
|
||||||
|
price_out: number
|
||||||
|
temperature?: number
|
||||||
|
max_tokens?: number
|
||||||
|
force_stream_mode?: boolean
|
||||||
|
extra_params?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个任务配置
|
||||||
|
*/
|
||||||
|
export interface PackTaskConfig {
|
||||||
|
model_list: string[]
|
||||||
|
temperature?: number
|
||||||
|
max_tokens?: number
|
||||||
|
slow_threshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有任务配置
|
||||||
|
*/
|
||||||
|
export interface PackTaskConfigs {
|
||||||
|
utils?: PackTaskConfig
|
||||||
|
utils_small?: PackTaskConfig
|
||||||
|
tool_use?: PackTaskConfig
|
||||||
|
replyer?: PackTaskConfig
|
||||||
|
planner?: PackTaskConfig
|
||||||
|
vlm?: PackTaskConfig
|
||||||
|
voice?: PackTaskConfig
|
||||||
|
embedding?: PackTaskConfig
|
||||||
|
lpmm_entity_extract?: PackTaskConfig
|
||||||
|
lpmm_rdf_build?: PackTaskConfig
|
||||||
|
lpmm_qa?: PackTaskConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack 列表项
|
||||||
|
*/
|
||||||
|
export interface PackListItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
version: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
reject_reason?: string
|
||||||
|
downloads: number
|
||||||
|
likes: number
|
||||||
|
tags?: string[]
|
||||||
|
provider_count: number
|
||||||
|
model_count: number
|
||||||
|
task_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整的 Pack 数据
|
||||||
|
*/
|
||||||
|
export interface ModelPack extends Omit<PackListItem, 'provider_count' | 'model_count' | 'task_count'> {
|
||||||
|
providers: PackProvider[]
|
||||||
|
models: PackModel[]
|
||||||
|
task_config: PackTaskConfigs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack 列表响应
|
||||||
|
*/
|
||||||
|
export interface ListPacksResponse {
|
||||||
|
packs: PackListItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total_pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Pack 时的选项
|
||||||
|
*/
|
||||||
|
export interface ApplyPackOptions {
|
||||||
|
apply_providers: boolean
|
||||||
|
apply_models: boolean
|
||||||
|
apply_task_config: boolean
|
||||||
|
task_mode: 'replace' | 'append'
|
||||||
|
selected_providers?: string[]
|
||||||
|
selected_models?: string[]
|
||||||
|
selected_tasks?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Pack 时的冲突检测结果
|
||||||
|
*/
|
||||||
|
export interface ApplyPackConflicts {
|
||||||
|
existing_providers: Array<{
|
||||||
|
pack_provider: PackProvider
|
||||||
|
local_providers: Array<{ // 改为数组,支持多个匹配
|
||||||
|
name: string
|
||||||
|
base_url: string
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
new_providers: PackProvider[]
|
||||||
|
conflicting_models: Array<{
|
||||||
|
pack_model: string
|
||||||
|
local_model: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ API 配置 ============
|
||||||
|
|
||||||
|
// Pack 服务基础 URL(Cloudflare Workers)
|
||||||
|
const PACK_SERVICE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
|
||||||
|
|
||||||
|
// ============ API 函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Pack 列表
|
||||||
|
*/
|
||||||
|
export async function listPacks(params?: {
|
||||||
|
status?: 'pending' | 'approved' | 'rejected' | 'all'
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
sort_by?: 'created_at' | 'downloads' | 'likes'
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
}): Promise<ListPacksResponse> {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
if (params?.status) searchParams.set('status', params.status)
|
||||||
|
if (params?.page) searchParams.set('page', params.page.toString())
|
||||||
|
if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
|
||||||
|
if (params?.search) searchParams.set('search', params.search)
|
||||||
|
if (params?.sort_by) searchParams.set('sort_by', params.sort_by)
|
||||||
|
if (params?.sort_order) searchParams.set('sort_order', params.sort_order)
|
||||||
|
|
||||||
|
const response = await fetch(`${PACK_SERVICE_URL}/pack?${searchParams.toString()}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取 Pack 列表失败: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个 Pack 详情
|
||||||
|
*/
|
||||||
|
export async function getPack(packId: string): Promise<ModelPack> {
|
||||||
|
const response = await fetch(`${PACK_SERVICE_URL}/pack/${packId}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取 Pack 失败: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || '获取 Pack 失败')
|
||||||
|
}
|
||||||
|
return data.pack
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新 Pack
|
||||||
|
*/
|
||||||
|
export async function createPack(pack: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
tags?: string[]
|
||||||
|
providers: PackProvider[]
|
||||||
|
models: PackModel[]
|
||||||
|
task_config: PackTaskConfigs
|
||||||
|
}): Promise<{ pack_id: string; message: string }> {
|
||||||
|
const response = await fetch(`${PACK_SERVICE_URL}/pack`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pack),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || '创建 Pack 失败')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 Pack 下载
|
||||||
|
*/
|
||||||
|
export async function recordPackDownload(packId: string, userId?: string): Promise<void> {
|
||||||
|
await fetch(`${PACK_SERVICE_URL}/pack/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pack_id: packId, user_id: userId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞/取消点赞 Pack
|
||||||
|
*/
|
||||||
|
export async function togglePackLike(packId: string, userId: string): Promise<{ likes: number; liked: boolean }> {
|
||||||
|
const response = await fetch(`${PACK_SERVICE_URL}/pack/like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pack_id: packId, user_id: userId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || '点赞失败')
|
||||||
|
}
|
||||||
|
return { likes: data.likes, liked: data.liked }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已点赞
|
||||||
|
*/
|
||||||
|
export async function checkPackLike(packId: string, userId: string): Promise<boolean> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${PACK_SERVICE_URL}/pack/like/check?pack_id=${packId}&user_id=${userId}`
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
return data.liked || false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 本地应用 Pack 相关 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测应用 Pack 时的冲突
|
||||||
|
*/
|
||||||
|
export async function detectPackConflicts(
|
||||||
|
pack: ModelPack
|
||||||
|
): Promise<ApplyPackConflicts> {
|
||||||
|
// 获取当前配置
|
||||||
|
const response = await fetchWithAuth('/api/webui/config/model')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取当前模型配置失败')
|
||||||
|
}
|
||||||
|
const responseData = await response.json()
|
||||||
|
const currentConfig = responseData.config || responseData
|
||||||
|
|
||||||
|
console.log('=== Pack Conflict Detection ===')
|
||||||
|
console.log('Pack providers:', pack.providers)
|
||||||
|
console.log('Local providers:', currentConfig.api_providers)
|
||||||
|
|
||||||
|
const conflicts: ApplyPackConflicts = {
|
||||||
|
existing_providers: [],
|
||||||
|
new_providers: [],
|
||||||
|
conflicting_models: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测提供商冲突
|
||||||
|
const localProviders = currentConfig.api_providers || []
|
||||||
|
for (const packProvider of pack.providers) {
|
||||||
|
console.log(`\nChecking pack provider: ${packProvider.name}`)
|
||||||
|
console.log(` Pack URL: ${packProvider.base_url}`)
|
||||||
|
console.log(` Normalized: ${normalizeUrl(packProvider.base_url)}`)
|
||||||
|
|
||||||
|
// 按 URL 匹配 - 找出所有匹配的本地提供商
|
||||||
|
const matchedProviders = localProviders.filter(
|
||||||
|
(p: { base_url: string; name: string }) => {
|
||||||
|
const localNormalized = normalizeUrl(p.base_url)
|
||||||
|
const packNormalized = normalizeUrl(packProvider.base_url)
|
||||||
|
console.log(` Comparing with local "${p.name}": ${p.base_url}`)
|
||||||
|
console.log(` Local normalized: ${localNormalized}`)
|
||||||
|
console.log(` Match: ${localNormalized === packNormalized}`)
|
||||||
|
return localNormalized === packNormalized
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchedProviders.length > 0) {
|
||||||
|
console.log(` ✓ Matched with ${matchedProviders.length} local provider(s):`, matchedProviders.map((p: {name: string}) => p.name).join(', '))
|
||||||
|
conflicts.existing_providers.push({
|
||||||
|
pack_provider: packProvider,
|
||||||
|
local_providers: matchedProviders.map((p: { name: string; base_url: string }) => ({
|
||||||
|
name: p.name,
|
||||||
|
base_url: p.base_url,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log(` ✗ No match found - will need API key`)
|
||||||
|
conflicts.new_providers.push(packProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测模型名称冲突
|
||||||
|
const localModels = currentConfig.models || []
|
||||||
|
console.log('\n=== Model Conflict Detection ===')
|
||||||
|
for (const packModel of pack.models) {
|
||||||
|
const conflictModel = localModels.find(
|
||||||
|
(m: { name: string }) => m.name === packModel.name
|
||||||
|
)
|
||||||
|
if (conflictModel) {
|
||||||
|
console.log(`Model conflict: ${packModel.name}`)
|
||||||
|
conflicts.conflicting_models.push({
|
||||||
|
pack_model: packModel.name,
|
||||||
|
local_model: conflictModel.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Detection Summary ===')
|
||||||
|
console.log(`Existing providers: ${conflicts.existing_providers.length}`)
|
||||||
|
console.log(`New providers: ${conflicts.new_providers.length}`)
|
||||||
|
console.log(`Conflicting models: ${conflicts.conflicting_models.length}`)
|
||||||
|
console.log('===========================\n')
|
||||||
|
|
||||||
|
return conflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Pack 到本地配置
|
||||||
|
*/
|
||||||
|
export async function applyPack(
|
||||||
|
pack: ModelPack,
|
||||||
|
options: ApplyPackOptions,
|
||||||
|
providerMapping: Record<string, string>, // pack_provider_name -> local_provider_name
|
||||||
|
newProviderApiKeys: Record<string, string>, // provider_name -> api_key
|
||||||
|
): Promise<void> {
|
||||||
|
// 获取当前配置
|
||||||
|
const response = await fetchWithAuth('/api/webui/config/model')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取当前模型配置失败')
|
||||||
|
}
|
||||||
|
const responseData = await response.json()
|
||||||
|
const currentConfig = responseData.config || responseData
|
||||||
|
|
||||||
|
// 1. 处理提供商
|
||||||
|
if (options.apply_providers) {
|
||||||
|
const providersToApply = options.selected_providers
|
||||||
|
? pack.providers.filter(p => options.selected_providers!.includes(p.name))
|
||||||
|
: pack.providers
|
||||||
|
|
||||||
|
for (const packProvider of providersToApply) {
|
||||||
|
// 检查是否映射到已有提供商
|
||||||
|
if (providerMapping[packProvider.name]) {
|
||||||
|
// 使用已有提供商,不需要添加
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新提供商
|
||||||
|
const apiKey = newProviderApiKeys[packProvider.name]
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(`提供商 "${packProvider.name}" 缺少 API Key`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProvider = {
|
||||||
|
...packProvider,
|
||||||
|
api_key: apiKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在同名提供商
|
||||||
|
const existingIndex = currentConfig.api_providers.findIndex(
|
||||||
|
(p: { name: string }) => p.name === packProvider.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// 覆盖
|
||||||
|
currentConfig.api_providers[existingIndex] = newProvider
|
||||||
|
} else {
|
||||||
|
// 添加
|
||||||
|
currentConfig.api_providers.push(newProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 处理模型
|
||||||
|
if (options.apply_models) {
|
||||||
|
const modelsToApply = options.selected_models
|
||||||
|
? pack.models.filter(m => options.selected_models!.includes(m.name))
|
||||||
|
: pack.models
|
||||||
|
|
||||||
|
for (const packModel of modelsToApply) {
|
||||||
|
// 映射提供商名称
|
||||||
|
const actualProvider = providerMapping[packModel.api_provider] || packModel.api_provider
|
||||||
|
|
||||||
|
const newModel = {
|
||||||
|
...packModel,
|
||||||
|
api_provider: actualProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在同名模型
|
||||||
|
const existingIndex = currentConfig.models.findIndex(
|
||||||
|
(m: { name: string }) => m.name === packModel.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// 覆盖
|
||||||
|
currentConfig.models[existingIndex] = newModel
|
||||||
|
} else {
|
||||||
|
// 添加
|
||||||
|
currentConfig.models.push(newModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 处理任务配置
|
||||||
|
if (options.apply_task_config) {
|
||||||
|
const taskKeys = options.selected_tasks || Object.keys(pack.task_config)
|
||||||
|
|
||||||
|
for (const taskKey of taskKeys) {
|
||||||
|
const packTaskConfig = pack.task_config[taskKey as keyof PackTaskConfigs]
|
||||||
|
if (!packTaskConfig) continue
|
||||||
|
|
||||||
|
// 映射模型名称(如果模型名称被跳过,则从任务列表中移除)
|
||||||
|
const appliedModelNames = new Set(
|
||||||
|
options.selected_models || pack.models.map(m => m.name)
|
||||||
|
)
|
||||||
|
const filteredModelList = packTaskConfig.model_list.filter(
|
||||||
|
name => appliedModelNames.has(name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredModelList.length === 0) continue
|
||||||
|
|
||||||
|
const newTaskConfig = {
|
||||||
|
...packTaskConfig,
|
||||||
|
model_list: filteredModelList,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.task_mode === 'replace') {
|
||||||
|
// 替换模式
|
||||||
|
currentConfig.model_task_config[taskKey] = newTaskConfig
|
||||||
|
} else {
|
||||||
|
// 追加模式
|
||||||
|
const existingConfig = currentConfig.model_task_config[taskKey]
|
||||||
|
if (existingConfig) {
|
||||||
|
// 合并模型列表(去重)
|
||||||
|
const mergedList = [...new Set([
|
||||||
|
...existingConfig.model_list,
|
||||||
|
...filteredModelList,
|
||||||
|
])]
|
||||||
|
currentConfig.model_task_config[taskKey] = {
|
||||||
|
...existingConfig,
|
||||||
|
model_list: mergedList,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentConfig.model_task_config[taskKey] = newTaskConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
const saveResponse = await fetchWithAuth('/api/webui/config/model', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(currentConfig),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!saveResponse.ok) {
|
||||||
|
throw new Error('保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从当前配置导出 Pack
|
||||||
|
*/
|
||||||
|
export async function exportCurrentConfigAsPack(params: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
tags?: string[]
|
||||||
|
selectedProviders?: string[]
|
||||||
|
selectedModels?: string[]
|
||||||
|
selectedTasks?: string[]
|
||||||
|
}): Promise<{
|
||||||
|
providers: PackProvider[]
|
||||||
|
models: PackModel[]
|
||||||
|
task_config: PackTaskConfigs
|
||||||
|
}> {
|
||||||
|
// 获取当前配置
|
||||||
|
const response = await fetchWithAuth('/api/webui/config/model')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取当前模型配置失败')
|
||||||
|
}
|
||||||
|
const responseData = await response.json()
|
||||||
|
|
||||||
|
// API 返回的格式是 { success: true, config: {...} }
|
||||||
|
if (!responseData.success || !responseData.config) {
|
||||||
|
throw new Error('获取配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentConfig = responseData.config
|
||||||
|
|
||||||
|
// 过滤提供商(移除 api_key)
|
||||||
|
let providers: PackProvider[] = (currentConfig.api_providers || []).map(
|
||||||
|
(p: { name: string; base_url: string; client_type: string; max_retry?: number; timeout?: number; retry_interval?: number }) => ({
|
||||||
|
name: p.name,
|
||||||
|
base_url: p.base_url,
|
||||||
|
client_type: p.client_type,
|
||||||
|
max_retry: p.max_retry,
|
||||||
|
timeout: p.timeout,
|
||||||
|
retry_interval: p.retry_interval,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (params.selectedProviders) {
|
||||||
|
providers = providers.filter(p => params.selectedProviders!.includes(p.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤模型
|
||||||
|
let models: PackModel[] = currentConfig.models || []
|
||||||
|
if (params.selectedModels) {
|
||||||
|
models = models.filter(m => params.selectedModels!.includes(m.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤任务配置
|
||||||
|
const task_config: PackTaskConfigs = {}
|
||||||
|
const allTasks = currentConfig.model_task_config || {}
|
||||||
|
const taskKeys = params.selectedTasks || Object.keys(allTasks)
|
||||||
|
|
||||||
|
for (const key of taskKeys) {
|
||||||
|
if (allTasks[key]) {
|
||||||
|
task_config[key as keyof PackTaskConfigs] = allTasks[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { providers, models, task_config }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 辅助函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化 URL 用于比较
|
||||||
|
*/
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
// 移除末尾斜杠,统一小写
|
||||||
|
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, '').toLowerCase()
|
||||||
|
} catch {
|
||||||
|
return url.toLowerCase().replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户 ID(用于统计)
|
||||||
|
*/
|
||||||
|
export function getPackUserId(): string {
|
||||||
|
const storageKey = 'maibot_pack_user_id'
|
||||||
|
let userId = localStorage.getItem(storageKey)
|
||||||
|
if (!userId) {
|
||||||
|
userId = 'pack_user_' + Math.random().toString(36).substring(2, 15)
|
||||||
|
localStorage.setItem(storageKey, userId)
|
||||||
|
}
|
||||||
|
return userId
|
||||||
|
}
|
||||||
138
dashboard/src/lib/person-api.ts
Normal file
138
dashboard/src/lib/person-api.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 人物信息管理 API
|
||||||
|
*/
|
||||||
|
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
PersonListResponse,
|
||||||
|
PersonDetailResponse,
|
||||||
|
PersonUpdateRequest,
|
||||||
|
PersonUpdateResponse,
|
||||||
|
PersonDeleteResponse,
|
||||||
|
PersonStatsResponse,
|
||||||
|
} from '@/types/person'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/person'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取人物信息列表
|
||||||
|
*/
|
||||||
|
export async function getPersonList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
is_known?: boolean
|
||||||
|
platform?: string
|
||||||
|
}): Promise<PersonListResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString())
|
||||||
|
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||||
|
if (params.search) queryParams.append('search', params.search)
|
||||||
|
if (params.is_known !== undefined) queryParams.append('is_known', params.is_known.toString())
|
||||||
|
if (params.platform) queryParams.append('platform', params.platform)
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取人物列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取人物详细信息
|
||||||
|
*/
|
||||||
|
export async function getPersonDetail(personId: string): Promise<PersonDetailResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取人物详情失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人物信息(增量更新)
|
||||||
|
*/
|
||||||
|
export async function updatePerson(
|
||||||
|
personId: string,
|
||||||
|
data: PersonUpdateRequest
|
||||||
|
): Promise<PersonUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '更新人物信息失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除人物信息
|
||||||
|
*/
|
||||||
|
export async function deletePerson(personId: string): Promise<PersonDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '删除人物信息失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取人物统计数据
|
||||||
|
*/
|
||||||
|
export async function getPersonStats(): Promise<PersonStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除人物信息
|
||||||
|
*/
|
||||||
|
export async function batchDeletePersons(personIds: string[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
deleted_count: number
|
||||||
|
failed_count: number
|
||||||
|
failed_ids: string[]
|
||||||
|
}> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ person_ids: personIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量删除失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
201
dashboard/src/lib/planner-api.ts
Normal file
201
dashboard/src/lib/planner-api.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { fetchWithAuth } from './fetch-with-auth'
|
||||||
|
|
||||||
|
// ========== 新的优化接口 ==========
|
||||||
|
|
||||||
|
export interface ChatSummary {
|
||||||
|
chat_id: string
|
||||||
|
plan_count: number
|
||||||
|
latest_timestamp: number
|
||||||
|
latest_filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannerOverview {
|
||||||
|
total_chats: number
|
||||||
|
total_plans: number
|
||||||
|
chats: ChatSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanLogSummary {
|
||||||
|
chat_id: string
|
||||||
|
timestamp: number
|
||||||
|
filename: string
|
||||||
|
action_count: number
|
||||||
|
action_types: string[] // 动作类型列表
|
||||||
|
total_plan_ms: number
|
||||||
|
llm_duration_ms: number
|
||||||
|
reasoning_preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanLogDetail {
|
||||||
|
type: string
|
||||||
|
chat_id: string
|
||||||
|
timestamp: number
|
||||||
|
prompt: string
|
||||||
|
reasoning: string
|
||||||
|
raw_output: string
|
||||||
|
actions: any[]
|
||||||
|
timing: {
|
||||||
|
prompt_build_ms: number
|
||||||
|
llm_duration_ms: number
|
||||||
|
total_plan_ms: number
|
||||||
|
loop_start_time: number
|
||||||
|
}
|
||||||
|
extra: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedChatLogs {
|
||||||
|
data: PlanLogSummary[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
chat_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取规划器总览 - 轻量级,只统计文件数量
|
||||||
|
*/
|
||||||
|
export async function getPlannerOverview(): Promise<PlannerOverview> {
|
||||||
|
const response = await fetchWithAuth('/api/planner/overview')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定聊天的规划日志列表(分页)
|
||||||
|
*/
|
||||||
|
export async function getChatLogs(chatId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedChatLogs> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
page_size: pageSize.toString()
|
||||||
|
})
|
||||||
|
if (search) {
|
||||||
|
params.append('search', search)
|
||||||
|
}
|
||||||
|
const response = await fetchWithAuth(`/api/planner/chat/${chatId}/logs?${params}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取规划日志详情 - 按需加载
|
||||||
|
*/
|
||||||
|
export async function getLogDetail(chatId: string, filename: string): Promise<PlanLogDetail> {
|
||||||
|
const response = await fetchWithAuth(`/api/planner/log/${chatId}/${filename}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 兼容旧接口 ==========
|
||||||
|
|
||||||
|
export interface PlannerStats {
|
||||||
|
total_chats: number
|
||||||
|
total_plans: number
|
||||||
|
avg_plan_time_ms: number
|
||||||
|
avg_llm_time_ms: number
|
||||||
|
recent_plans: PlanLogSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedPlanLogs {
|
||||||
|
data: PlanLogSummary[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlannerStats(): Promise<PlannerStats> {
|
||||||
|
const response = await fetchWithAuth('/api/planner/stats')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllLogs(page = 1, pageSize = 20): Promise<PaginatedPlanLogs> {
|
||||||
|
const response = await fetchWithAuth(`/api/planner/all-logs?page=${page}&page_size=${pageSize}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatList(): Promise<string[]> {
|
||||||
|
const response = await fetchWithAuth('/api/planner/chats')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 回复器接口 ==========
|
||||||
|
|
||||||
|
export interface ReplierChatSummary {
|
||||||
|
chat_id: string
|
||||||
|
reply_count: number
|
||||||
|
latest_timestamp: number
|
||||||
|
latest_filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplierOverview {
|
||||||
|
total_chats: number
|
||||||
|
total_replies: number
|
||||||
|
chats: ReplierChatSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyLogSummary {
|
||||||
|
chat_id: string
|
||||||
|
timestamp: number
|
||||||
|
filename: string
|
||||||
|
model: string
|
||||||
|
success: boolean
|
||||||
|
llm_ms: number
|
||||||
|
overall_ms: number
|
||||||
|
output_preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyLogDetail {
|
||||||
|
type: string
|
||||||
|
chat_id: string
|
||||||
|
timestamp: number
|
||||||
|
prompt: string
|
||||||
|
output: string
|
||||||
|
processed_output: string[]
|
||||||
|
model: string
|
||||||
|
reasoning: string
|
||||||
|
think_level: number
|
||||||
|
timing: {
|
||||||
|
prompt_ms: number
|
||||||
|
overall_ms: number
|
||||||
|
timing_logs: string[]
|
||||||
|
llm_ms: number
|
||||||
|
almost_zero: string
|
||||||
|
}
|
||||||
|
error: string | null
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedReplyLogs {
|
||||||
|
data: ReplyLogSummary[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
chat_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取回复器总览 - 轻量级,只统计文件数量
|
||||||
|
*/
|
||||||
|
export async function getReplierOverview(): Promise<ReplierOverview> {
|
||||||
|
const response = await fetchWithAuth('/api/replier/overview')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定聊天的回复日志列表(分页)
|
||||||
|
*/
|
||||||
|
export async function getReplyChatLogs(chatId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedReplyLogs> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
page_size: pageSize.toString()
|
||||||
|
})
|
||||||
|
if (search) {
|
||||||
|
params.append('search', search)
|
||||||
|
}
|
||||||
|
const response = await fetchWithAuth(`/api/replier/chat/${chatId}/logs?${params}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取回复日志详情 - 按需加载
|
||||||
|
*/
|
||||||
|
export async function getReplyLogDetail(chatId: string, filename: string): Promise<ReplyLogDetail> {
|
||||||
|
const response = await fetchWithAuth(`/api/replier/log/${chatId}/${filename}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
722
dashboard/src/lib/plugin-api.ts
Normal file
722
dashboard/src/lib/plugin-api.ts
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||||
|
import type { PluginInfo } from '@/types/plugin'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git 安装状态
|
||||||
|
*/
|
||||||
|
export interface GitStatus {
|
||||||
|
installed: boolean
|
||||||
|
version?: string
|
||||||
|
path?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 麦麦版本信息
|
||||||
|
*/
|
||||||
|
export interface MaimaiVersion {
|
||||||
|
version: string
|
||||||
|
version_major: number
|
||||||
|
version_minor: number
|
||||||
|
version_patch: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已安装插件信息
|
||||||
|
*/
|
||||||
|
export interface InstalledPlugin {
|
||||||
|
id: string
|
||||||
|
manifest: {
|
||||||
|
manifest_version: number
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
author: {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
license: string
|
||||||
|
host_application: {
|
||||||
|
min_version: string
|
||||||
|
max_version?: string
|
||||||
|
}
|
||||||
|
homepage_url?: string
|
||||||
|
repository_url?: string
|
||||||
|
keywords?: string[]
|
||||||
|
categories?: string[]
|
||||||
|
[key: string]: unknown // 允许其他字段
|
||||||
|
}
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件加载进度
|
||||||
|
*/
|
||||||
|
export interface PluginLoadProgress {
|
||||||
|
operation: 'idle' | 'fetch' | 'install' | 'uninstall' | 'update'
|
||||||
|
stage: 'idle' | 'loading' | 'success' | 'error'
|
||||||
|
progress: number // 0-100
|
||||||
|
message: string
|
||||||
|
error?: string
|
||||||
|
plugin_id?: string
|
||||||
|
total_plugins: number
|
||||||
|
loaded_plugins: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件仓库配置
|
||||||
|
*/
|
||||||
|
const PLUGIN_REPO_OWNER = 'Mai-with-u'
|
||||||
|
const PLUGIN_REPO_NAME = 'plugin-repo'
|
||||||
|
const PLUGIN_REPO_BRANCH = 'main'
|
||||||
|
const PLUGIN_DETAILS_FILE = 'plugin_details.json'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件列表 API 响应类型(只包含我们需要的字段)
|
||||||
|
*/
|
||||||
|
interface PluginApiResponse {
|
||||||
|
id: string
|
||||||
|
manifest: {
|
||||||
|
manifest_version: number
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
author: {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
license: string
|
||||||
|
host_application: {
|
||||||
|
min_version: string
|
||||||
|
max_version?: string
|
||||||
|
}
|
||||||
|
homepage_url?: string
|
||||||
|
repository_url?: string
|
||||||
|
keywords: string[]
|
||||||
|
categories?: string[]
|
||||||
|
default_locale: string
|
||||||
|
locales_path?: string
|
||||||
|
}
|
||||||
|
// 可能还有其他字段,但我们不关心
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从远程获取插件列表(通过后端代理避免 CORS)
|
||||||
|
*/
|
||||||
|
export async function fetchPluginList(): Promise<PluginInfo[]> {
|
||||||
|
try {
|
||||||
|
// 通过后端 API 获取 Raw 文件
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
owner: PLUGIN_REPO_OWNER,
|
||||||
|
repo: PLUGIN_REPO_NAME,
|
||||||
|
branch: PLUGIN_REPO_BRANCH,
|
||||||
|
file_path: PLUGIN_DETAILS_FILE
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
// 检查后端返回的结果
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.error || '获取插件列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: PluginApiResponse[] = JSON.parse(result.data)
|
||||||
|
|
||||||
|
// 转换为 PluginInfo 格式,并过滤掉无效数据
|
||||||
|
const pluginList = data
|
||||||
|
.filter(item => {
|
||||||
|
// 验证必需字段
|
||||||
|
if (!item?.id || !item?.manifest) {
|
||||||
|
console.warn('跳过无效插件数据:', item)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!item.manifest.name || !item.manifest.version) {
|
||||||
|
console.warn('跳过缺少必需字段的插件:', item.id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
manifest: {
|
||||||
|
manifest_version: item.manifest.manifest_version || 1,
|
||||||
|
name: item.manifest.name,
|
||||||
|
version: item.manifest.version,
|
||||||
|
description: item.manifest.description || '',
|
||||||
|
author: item.manifest.author || { name: 'Unknown' },
|
||||||
|
license: item.manifest.license || 'Unknown',
|
||||||
|
host_application: item.manifest.host_application || { min_version: '0.0.0' },
|
||||||
|
homepage_url: item.manifest.homepage_url,
|
||||||
|
repository_url: item.manifest.repository_url,
|
||||||
|
keywords: item.manifest.keywords || [],
|
||||||
|
categories: item.manifest.categories || [],
|
||||||
|
default_locale: item.manifest.default_locale || 'zh-CN',
|
||||||
|
locales_path: item.manifest.locales_path,
|
||||||
|
},
|
||||||
|
// 默认值,这些信息可能需要从其他 API 获取
|
||||||
|
downloads: 0,
|
||||||
|
rating: 0,
|
||||||
|
review_count: 0,
|
||||||
|
installed: false,
|
||||||
|
published_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return pluginList
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plugin list:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查本机 Git 安装状态
|
||||||
|
*/
|
||||||
|
export async function checkGitStatus(): Promise<GitStatus> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/git-status')
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check Git status:', error)
|
||||||
|
// 返回未安装状态
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
error: '无法检测 Git 安装状态'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取麦麦版本信息
|
||||||
|
*/
|
||||||
|
export async function getMaimaiVersion(): Promise<MaimaiVersion> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/version')
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get Maimai version:', error)
|
||||||
|
// 返回默认版本
|
||||||
|
return {
|
||||||
|
version: '0.0.0',
|
||||||
|
version_major: 0,
|
||||||
|
version_minor: 0,
|
||||||
|
version_patch: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较版本号
|
||||||
|
*
|
||||||
|
* @param pluginMinVersion 插件要求的最小版本
|
||||||
|
* @param pluginMaxVersion 插件要求的最大版本(可选)
|
||||||
|
* @param maimaiVersion 麦麦当前版本
|
||||||
|
* @returns true 表示兼容,false 表示不兼容
|
||||||
|
*/
|
||||||
|
export function isPluginCompatible(
|
||||||
|
pluginMinVersion: string,
|
||||||
|
pluginMaxVersion: string | undefined,
|
||||||
|
maimaiVersion: MaimaiVersion
|
||||||
|
): boolean {
|
||||||
|
// 解析插件最小版本
|
||||||
|
const minParts = pluginMinVersion.split('.').map(p => parseInt(p) || 0)
|
||||||
|
const minMajor = minParts[0] || 0
|
||||||
|
const minMinor = minParts[1] || 0
|
||||||
|
const minPatch = minParts[2] || 0
|
||||||
|
|
||||||
|
// 检查最小版本
|
||||||
|
if (maimaiVersion.version_major < minMajor) return false
|
||||||
|
if (maimaiVersion.version_major === minMajor && maimaiVersion.version_minor < minMinor) return false
|
||||||
|
if (maimaiVersion.version_major === minMajor &&
|
||||||
|
maimaiVersion.version_minor === minMinor &&
|
||||||
|
maimaiVersion.version_patch < minPatch) return false
|
||||||
|
|
||||||
|
// 检查最大版本(如果有)
|
||||||
|
if (pluginMaxVersion) {
|
||||||
|
const maxParts = pluginMaxVersion.split('.').map(p => parseInt(p) || 0)
|
||||||
|
const maxMajor = maxParts[0] || 0
|
||||||
|
const maxMinor = maxParts[1] || 0
|
||||||
|
const maxPatch = maxParts[2] || 0
|
||||||
|
|
||||||
|
if (maimaiVersion.version_major > maxMajor) return false
|
||||||
|
if (maimaiVersion.version_major === maxMajor && maimaiVersion.version_minor > maxMinor) return false
|
||||||
|
if (maimaiVersion.version_major === maxMajor &&
|
||||||
|
maimaiVersion.version_minor === maxMinor &&
|
||||||
|
maimaiVersion.version_patch > maxPatch) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocket 临时认证 token
|
||||||
|
*/
|
||||||
|
async function getWsToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/webui/ws-token')
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('获取 WebSocket token 失败:', response.status)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.token) {
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 WebSocket token 失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接插件加载进度 WebSocket
|
||||||
|
*
|
||||||
|
* 使用临时 token 进行认证,异步获取 token 后连接
|
||||||
|
*/
|
||||||
|
export async function connectPluginProgressWebSocket(
|
||||||
|
onProgress: (progress: PluginLoadProgress) => void,
|
||||||
|
onError?: (error: Event) => void
|
||||||
|
): Promise<WebSocket | null> {
|
||||||
|
// 先获取临时 token
|
||||||
|
const wsToken = await getWsToken()
|
||||||
|
if (!wsToken) {
|
||||||
|
console.warn('无法获取 WebSocket token,可能未登录')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = window.location.host
|
||||||
|
const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress?token=${encodeURIComponent(wsToken)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('Plugin progress WebSocket connected')
|
||||||
|
// 发送心跳
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send('ping')
|
||||||
|
} else {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
// 忽略心跳响应
|
||||||
|
if (event.data === 'pong') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(event.data) as PluginLoadProgress
|
||||||
|
onProgress(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse progress data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('Plugin progress WebSocket error:', error)
|
||||||
|
onError?.(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('Plugin progress WebSocket disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
return ws
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建 WebSocket 连接失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已安装插件列表
|
||||||
|
*/
|
||||||
|
export async function getInstalledPlugins(): Promise<InstalledPlugin[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/installed', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '获取已安装插件列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.plugins || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get installed plugins:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查插件是否已安装
|
||||||
|
*/
|
||||||
|
export function checkPluginInstalled(pluginId: string, installedPlugins: InstalledPlugin[]): boolean {
|
||||||
|
return installedPlugins.some(p => p.id === pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已安装插件的版本
|
||||||
|
*/
|
||||||
|
export function getInstalledPluginVersion(pluginId: string, installedPlugins: InstalledPlugin[]): string | undefined {
|
||||||
|
const plugin = installedPlugins.find(p => p.id === pluginId)
|
||||||
|
if (!plugin) return undefined
|
||||||
|
|
||||||
|
// 兼容两种格式:新格式有 manifest,旧格式直接有 version
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return plugin.manifest?.version || (plugin as any).version
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装插件
|
||||||
|
*/
|
||||||
|
export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/install', {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_id: pluginId,
|
||||||
|
repository_url: repositoryUrl,
|
||||||
|
branch: branch
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '安装失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载插件
|
||||||
|
*/
|
||||||
|
export async function uninstallPlugin(pluginId: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/uninstall', {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_id: pluginId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '卸载失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新插件
|
||||||
|
*/
|
||||||
|
export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string; old_version: string; new_version: string }> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/update', {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_id: pluginId,
|
||||||
|
repository_url: repositoryUrl,
|
||||||
|
branch: branch
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '更新失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============ 插件配置管理 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表项字段定义(用于 object 类型的数组项)
|
||||||
|
*/
|
||||||
|
export interface ItemFieldDefinition {
|
||||||
|
type: string
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
default?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置字段定义
|
||||||
|
*/
|
||||||
|
export interface ConfigFieldSchema {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
default: unknown
|
||||||
|
description: string
|
||||||
|
example?: string
|
||||||
|
required: boolean
|
||||||
|
choices?: unknown[]
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
pattern?: string
|
||||||
|
max_length?: number
|
||||||
|
label: string
|
||||||
|
placeholder?: string
|
||||||
|
hint?: string
|
||||||
|
icon?: string
|
||||||
|
hidden: boolean
|
||||||
|
disabled: boolean
|
||||||
|
order: number
|
||||||
|
input_type?: string
|
||||||
|
ui_type: string
|
||||||
|
rows?: number
|
||||||
|
group?: string
|
||||||
|
depends_on?: string
|
||||||
|
depends_value?: unknown
|
||||||
|
// 列表类型专用
|
||||||
|
item_type?: string // "string" | "number" | "object"
|
||||||
|
item_fields?: Record<string, ItemFieldDefinition>
|
||||||
|
min_items?: number
|
||||||
|
max_items?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置节定义
|
||||||
|
*/
|
||||||
|
export interface ConfigSectionSchema {
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
icon?: string
|
||||||
|
collapsed: boolean
|
||||||
|
order: number
|
||||||
|
fields: Record<string, ConfigFieldSchema>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置标签页定义
|
||||||
|
*/
|
||||||
|
export interface ConfigTabSchema {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
sections: string[]
|
||||||
|
icon?: string
|
||||||
|
order: number
|
||||||
|
badge?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置布局定义
|
||||||
|
*/
|
||||||
|
export interface ConfigLayoutSchema {
|
||||||
|
type: 'auto' | 'tabs' | 'pages'
|
||||||
|
tabs: ConfigTabSchema[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件配置 Schema
|
||||||
|
*/
|
||||||
|
export interface PluginConfigSchema {
|
||||||
|
plugin_id: string
|
||||||
|
plugin_info: {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
}
|
||||||
|
sections: Record<string, ConfigSectionSchema>
|
||||||
|
layout: ConfigLayoutSchema
|
||||||
|
_note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件配置 Schema
|
||||||
|
*/
|
||||||
|
export async function getPluginConfigSchema(pluginId: string): Promise<PluginConfigSchema> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text)
|
||||||
|
throw new Error(error.detail || '获取配置 Schema 失败')
|
||||||
|
} catch {
|
||||||
|
throw new Error(`获取配置 Schema 失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '获取配置 Schema 失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件当前配置值
|
||||||
|
*/
|
||||||
|
export async function getPluginConfig(pluginId: string): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text)
|
||||||
|
throw new Error(error.detail || '获取配置失败')
|
||||||
|
} catch {
|
||||||
|
throw new Error(`获取配置失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '获取配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件原始 TOML 配置
|
||||||
|
*/
|
||||||
|
export async function getPluginConfigRaw(pluginId: string): Promise<string> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text)
|
||||||
|
throw new Error(error.detail || '获取配置失败')
|
||||||
|
} catch {
|
||||||
|
throw new Error(`获取配置失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '获取配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新插件配置
|
||||||
|
*/
|
||||||
|
export async function updatePluginConfig(
|
||||||
|
pluginId: string,
|
||||||
|
config: Record<string, unknown>
|
||||||
|
): Promise<{ success: boolean; message: string; note?: string }> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ config })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '保存配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新插件原始 TOML 配置
|
||||||
|
*/
|
||||||
|
export async function updatePluginConfigRaw(
|
||||||
|
pluginId: string,
|
||||||
|
configToml: string
|
||||||
|
): Promise<{ success: boolean; message: string; note?: string }> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ config: configToml })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '保存配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置插件配置为默认值
|
||||||
|
*/
|
||||||
|
export async function resetPluginConfig(
|
||||||
|
pluginId: string
|
||||||
|
): Promise<{ success: boolean; message: string; backup?: string }> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '重置配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换插件启用状态
|
||||||
|
*/
|
||||||
|
export async function togglePlugin(
|
||||||
|
pluginId: string
|
||||||
|
): Promise<{ success: boolean; enabled: boolean; message: string; note?: string }> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '切换状态失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
244
dashboard/src/lib/plugin-stats.ts
Normal file
244
dashboard/src/lib/plugin-stats.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* 插件统计 API 客户端
|
||||||
|
* 用于与 Cloudflare Workers 统计服务交互
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 配置统计服务 API 地址(所有用户共享的云端统计服务)
|
||||||
|
const STATS_API_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
|
||||||
|
|
||||||
|
export interface PluginStatsData {
|
||||||
|
plugin_id: string
|
||||||
|
likes: number
|
||||||
|
dislikes: number
|
||||||
|
downloads: number
|
||||||
|
rating: number
|
||||||
|
rating_count: number
|
||||||
|
recent_ratings?: Array<{
|
||||||
|
user_id: string
|
||||||
|
rating: number
|
||||||
|
comment?: string
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsResponse {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
remaining?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件统计数据
|
||||||
|
*/
|
||||||
|
export async function getPluginStats(pluginId: string): Promise<PluginStatsData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/${pluginId}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch plugin stats:', response.statusText)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching plugin stats:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞插件
|
||||||
|
*/
|
||||||
|
export async function likePlugin(pluginId: string, userId?: string): Promise<StatsResponse> {
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId, user_id: finalUserId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { success: false, error: '操作过于频繁,请稍后再试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: data.error || '点赞失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, ...data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error liking plugin:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点踩插件
|
||||||
|
*/
|
||||||
|
export async function dislikePlugin(pluginId: string, userId?: string): Promise<StatsResponse> {
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/dislike`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId, user_id: finalUserId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { success: false, error: '操作过于频繁,请稍后再试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: data.error || '点踩失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, ...data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disliking plugin:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评分插件
|
||||||
|
*/
|
||||||
|
export async function ratePlugin(
|
||||||
|
pluginId: string,
|
||||||
|
rating: number,
|
||||||
|
comment?: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<StatsResponse> {
|
||||||
|
if (rating < 1 || rating > 5) {
|
||||||
|
return { success: false, error: '评分必须在 1-5 之间' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/rate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId, rating, comment, user_id: finalUserId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { success: false, error: '每天最多评分 3 次' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: data.error || '评分失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, ...data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rating plugin:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录插件下载
|
||||||
|
*/
|
||||||
|
export async function recordPluginDownload(pluginId: string): Promise<StatsResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
// 下载统计被限流时静默失败,不影响用户体验
|
||||||
|
console.warn('Download recording rate limited')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to record download:', data.error)
|
||||||
|
return { success: false, error: data.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, ...data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error recording download:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户指纹(基于浏览器特征)
|
||||||
|
* 用于在未登录时识别用户,防止重复投票
|
||||||
|
*/
|
||||||
|
export function generateUserFingerprint(): string {
|
||||||
|
const nav = navigator as Navigator & { deviceMemory?: number }
|
||||||
|
const features = [
|
||||||
|
navigator.userAgent,
|
||||||
|
navigator.language,
|
||||||
|
navigator.languages?.join(',') || '',
|
||||||
|
navigator.platform,
|
||||||
|
navigator.hardwareConcurrency || 0,
|
||||||
|
screen.width,
|
||||||
|
screen.height,
|
||||||
|
screen.colorDepth,
|
||||||
|
screen.pixelDepth,
|
||||||
|
new Date().getTimezoneOffset(),
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
navigator.maxTouchPoints || 0,
|
||||||
|
nav.deviceMemory || 0,
|
||||||
|
].join('|')
|
||||||
|
|
||||||
|
// 简单哈希函数
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < features.length; i++) {
|
||||||
|
const char = features.charCodeAt(i)
|
||||||
|
hash = ((hash << 5) - hash) + char
|
||||||
|
hash = hash & hash // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
|
||||||
|
return `fp_${Math.abs(hash).toString(36)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成或获取用户 UUID
|
||||||
|
* 存储在 localStorage 中持久化
|
||||||
|
*/
|
||||||
|
export function getUserId(): string {
|
||||||
|
const STORAGE_KEY = 'maibot_user_id'
|
||||||
|
|
||||||
|
// 尝试从 localStorage 获取
|
||||||
|
let userId = localStorage.getItem(STORAGE_KEY)
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
// 生成新的 UUID
|
||||||
|
const fingerprint = generateUserFingerprint()
|
||||||
|
const timestamp = Date.now().toString(36)
|
||||||
|
const random = Math.random().toString(36).substring(2, 15)
|
||||||
|
|
||||||
|
userId = `${fingerprint}_${timestamp}_${random}`
|
||||||
|
|
||||||
|
// 存储到 localStorage
|
||||||
|
localStorage.setItem(STORAGE_KEY, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId
|
||||||
|
}
|
||||||
350
dashboard/src/lib/restart-context.tsx
Normal file
350
dashboard/src/lib/restart-context.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* 重启管理 Context
|
||||||
|
*
|
||||||
|
* 提供全局的重启状态管理和触发能力
|
||||||
|
* 使用方式:
|
||||||
|
* const { triggerRestart, isRestarting } = useRestart()
|
||||||
|
* triggerRestart() // 触发重启
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { restartMaiBot } from './system-api'
|
||||||
|
|
||||||
|
// ============ 类型定义 ============
|
||||||
|
|
||||||
|
export type RestartStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'requesting'
|
||||||
|
| 'restarting'
|
||||||
|
| 'checking'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export interface RestartState {
|
||||||
|
status: RestartStatus
|
||||||
|
progress: number
|
||||||
|
elapsedTime: number
|
||||||
|
checkAttempts: number
|
||||||
|
maxAttempts: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestartContextValue {
|
||||||
|
/** 当前重启状态 */
|
||||||
|
state: RestartState
|
||||||
|
/** 是否正在重启中(任何非 idle 状态) */
|
||||||
|
isRestarting: boolean
|
||||||
|
/** 触发重启 */
|
||||||
|
triggerRestart: (options?: TriggerRestartOptions) => Promise<void>
|
||||||
|
/** 重置状态(用于失败后重试) */
|
||||||
|
resetState: () => void
|
||||||
|
/** 手动开始健康检查(用于重试) */
|
||||||
|
retryHealthCheck: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerRestartOptions {
|
||||||
|
/** 重启前延迟(毫秒),用于显示提示 */
|
||||||
|
delay?: number
|
||||||
|
/** 自定义重启消息 */
|
||||||
|
message?: string
|
||||||
|
/** 跳过 API 调用(用于后端已触发重启的情况) */
|
||||||
|
skipApiCall?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 配置常量 ============
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
/** 初始等待时间(毫秒),给后端重启时间 */
|
||||||
|
INITIAL_DELAY: 3000,
|
||||||
|
/** 健康检查间隔(毫秒) */
|
||||||
|
CHECK_INTERVAL: 2000,
|
||||||
|
/** 健康检查超时(毫秒) */
|
||||||
|
CHECK_TIMEOUT: 3000,
|
||||||
|
/** 最大检查次数 */
|
||||||
|
MAX_ATTEMPTS: 60,
|
||||||
|
/** 进度条更新间隔(毫秒) */
|
||||||
|
PROGRESS_INTERVAL: 200,
|
||||||
|
/** 成功后跳转延迟(毫秒) */
|
||||||
|
SUCCESS_REDIRECT_DELAY: 1500,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ============ Context ============
|
||||||
|
|
||||||
|
const RestartContext = createContext<RestartContextValue | null>(null)
|
||||||
|
|
||||||
|
// ============ Provider ============
|
||||||
|
|
||||||
|
interface RestartProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
/** 重启成功后的回调 */
|
||||||
|
onRestartComplete?: () => void
|
||||||
|
/** 重启失败后的回调 */
|
||||||
|
onRestartFailed?: (error: string) => void
|
||||||
|
/** 自定义健康检查 URL */
|
||||||
|
healthCheckUrl?: string
|
||||||
|
/** 自定义最大尝试次数 */
|
||||||
|
maxAttempts?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestartProvider({
|
||||||
|
children,
|
||||||
|
onRestartComplete,
|
||||||
|
onRestartFailed,
|
||||||
|
healthCheckUrl = '/api/webui/system/status',
|
||||||
|
maxAttempts = CONFIG.MAX_ATTEMPTS,
|
||||||
|
}: RestartProviderProps) {
|
||||||
|
const [state, setState] = useState<RestartState>({
|
||||||
|
status: 'idle',
|
||||||
|
progress: 0,
|
||||||
|
elapsedTime: 0,
|
||||||
|
checkAttempts: 0,
|
||||||
|
maxAttempts,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用 useRef 存储定时器引用,避免闭包陷阱
|
||||||
|
const timersRef = useRef<{
|
||||||
|
progress?: ReturnType<typeof setInterval>
|
||||||
|
elapsed?: ReturnType<typeof setInterval>
|
||||||
|
check?: ReturnType<typeof setTimeout>
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
// 清理所有定时器
|
||||||
|
const clearAllTimers = useCallback(() => {
|
||||||
|
const timers = timersRef.current
|
||||||
|
if (timers.progress) {
|
||||||
|
clearInterval(timers.progress)
|
||||||
|
timers.progress = undefined
|
||||||
|
}
|
||||||
|
if (timers.elapsed) {
|
||||||
|
clearInterval(timers.elapsed)
|
||||||
|
timers.elapsed = undefined
|
||||||
|
}
|
||||||
|
if (timers.check) {
|
||||||
|
clearTimeout(timers.check)
|
||||||
|
timers.check = undefined
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
clearAllTimers()
|
||||||
|
setState({
|
||||||
|
status: 'idle',
|
||||||
|
progress: 0,
|
||||||
|
elapsedTime: 0,
|
||||||
|
checkAttempts: 0,
|
||||||
|
maxAttempts,
|
||||||
|
})
|
||||||
|
}, [clearAllTimers, maxAttempts])
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
const checkHealth = useCallback(
|
||||||
|
async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
CONFIG.CHECK_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await fetch(healthCheckUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
// 网络错误、超时等都视为服务不可用,这是正常的
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[healthCheckUrl]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 开始健康检查循环
|
||||||
|
const startHealthCheck = useCallback(() => {
|
||||||
|
let currentAttempt = 0
|
||||||
|
|
||||||
|
const doCheck = async () => {
|
||||||
|
currentAttempt++
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'checking',
|
||||||
|
checkAttempts: currentAttempt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isHealthy = await checkHealth()
|
||||||
|
|
||||||
|
if (isHealthy) {
|
||||||
|
// 成功
|
||||||
|
clearAllTimers()
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'success',
|
||||||
|
progress: 100,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 延迟后跳转
|
||||||
|
setTimeout(() => {
|
||||||
|
onRestartComplete?.()
|
||||||
|
// 默认跳转到 auth 页面
|
||||||
|
window.location.href = '/auth'
|
||||||
|
}, CONFIG.SUCCESS_REDIRECT_DELAY)
|
||||||
|
} else if (currentAttempt >= maxAttempts) {
|
||||||
|
// 失败
|
||||||
|
clearAllTimers()
|
||||||
|
const error = `健康检查超时 (${currentAttempt}/${maxAttempts})`
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'failed',
|
||||||
|
error,
|
||||||
|
}))
|
||||||
|
onRestartFailed?.(error)
|
||||||
|
} else {
|
||||||
|
// 继续检查
|
||||||
|
const checkTimer = setTimeout(doCheck, CONFIG.CHECK_INTERVAL)
|
||||||
|
timersRef.current.check = checkTimer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doCheck()
|
||||||
|
}, [checkHealth, clearAllTimers, maxAttempts, onRestartComplete, onRestartFailed])
|
||||||
|
|
||||||
|
// 重试健康检查
|
||||||
|
const retryHealthCheck = useCallback(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'checking',
|
||||||
|
checkAttempts: 0,
|
||||||
|
error: undefined,
|
||||||
|
}))
|
||||||
|
startHealthCheck()
|
||||||
|
}, [startHealthCheck])
|
||||||
|
|
||||||
|
// 触发重启
|
||||||
|
const triggerRestart = useCallback(
|
||||||
|
async (options?: TriggerRestartOptions) => {
|
||||||
|
const { delay = 0, skipApiCall = false } = options ?? {}
|
||||||
|
|
||||||
|
// 已经在重启中,忽略
|
||||||
|
if (state.status !== 'idle' && state.status !== 'failed') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
clearAllTimers()
|
||||||
|
setState({
|
||||||
|
status: 'requesting',
|
||||||
|
progress: 0,
|
||||||
|
elapsedTime: 0,
|
||||||
|
checkAttempts: 0,
|
||||||
|
maxAttempts,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 可选延迟
|
||||||
|
if (delay > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用重启 API
|
||||||
|
if (!skipApiCall) {
|
||||||
|
try {
|
||||||
|
setState((prev) => ({ ...prev, status: 'restarting' }))
|
||||||
|
// 重启 API 可能不返回响应(服务立即关闭)
|
||||||
|
await Promise.race([
|
||||||
|
restartMaiBot(),
|
||||||
|
// 5秒超时,超时也视为成功(服务已关闭)
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
// API 调用失败也是正常的(服务已关闭)
|
||||||
|
// 继续进行健康检查
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState((prev) => ({ ...prev, status: 'restarting' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动进度条动画
|
||||||
|
const progressTimer = setInterval(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
progress: prev.progress >= 90 ? prev.progress : prev.progress + 1,
|
||||||
|
}))
|
||||||
|
}, CONFIG.PROGRESS_INTERVAL)
|
||||||
|
|
||||||
|
// 启动计时器
|
||||||
|
const elapsedTimer = setInterval(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
elapsedTime: prev.elapsedTime + 1,
|
||||||
|
}))
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
timersRef.current.progress = progressTimer
|
||||||
|
timersRef.current.elapsed = elapsedTimer
|
||||||
|
|
||||||
|
// 延迟后开始健康检查
|
||||||
|
setTimeout(() => {
|
||||||
|
startHealthCheck()
|
||||||
|
}, CONFIG.INITIAL_DELAY)
|
||||||
|
},
|
||||||
|
[state.status, clearAllTimers, maxAttempts, startHealthCheck]
|
||||||
|
)
|
||||||
|
|
||||||
|
const contextValue: RestartContextValue = {
|
||||||
|
state,
|
||||||
|
isRestarting: state.status !== 'idle',
|
||||||
|
triggerRestart,
|
||||||
|
resetState,
|
||||||
|
retryHealthCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RestartContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</RestartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Hook ============
|
||||||
|
|
||||||
|
export function useRestart(): RestartContextValue {
|
||||||
|
const context = useContext(RestartContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRestart must be used within a RestartProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 便捷 Hook(无需 Provider) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 独立的重启 Hook,不依赖 Provider
|
||||||
|
* 适用于只需要触发重启,不需要全局状态的场景
|
||||||
|
*/
|
||||||
|
export function useRestartAction() {
|
||||||
|
const [isRestarting, setIsRestarting] = useState(false)
|
||||||
|
|
||||||
|
const triggerRestart = useCallback(async () => {
|
||||||
|
if (isRestarting) return
|
||||||
|
|
||||||
|
setIsRestarting(true)
|
||||||
|
try {
|
||||||
|
await restartMaiBot()
|
||||||
|
} catch {
|
||||||
|
// 忽略错误,服务可能已关闭
|
||||||
|
}
|
||||||
|
}, [isRestarting])
|
||||||
|
|
||||||
|
return { isRestarting, triggerRestart }
|
||||||
|
}
|
||||||
282
dashboard/src/lib/settings-manager.ts
Normal file
282
dashboard/src/lib/settings-manager.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* 前端设置管理器
|
||||||
|
* 统一管理所有前端 localStorage 设置
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 所有设置的 key 定义
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
// 外观设置
|
||||||
|
THEME: 'maibot-ui-theme',
|
||||||
|
ACCENT_COLOR: 'accent-color',
|
||||||
|
ENABLE_ANIMATIONS: 'maibot-animations',
|
||||||
|
ENABLE_WAVES_BACKGROUND: 'maibot-waves-background',
|
||||||
|
|
||||||
|
// 性能与存储设置
|
||||||
|
LOG_CACHE_SIZE: 'maibot-log-cache-size',
|
||||||
|
LOG_AUTO_SCROLL: 'maibot-log-auto-scroll',
|
||||||
|
LOG_FONT_SIZE: 'maibot-log-font-size',
|
||||||
|
LOG_LINE_SPACING: 'maibot-log-line-spacing',
|
||||||
|
DATA_SYNC_INTERVAL: 'maibot-data-sync-interval',
|
||||||
|
WS_RECONNECT_INTERVAL: 'maibot-ws-reconnect-interval',
|
||||||
|
WS_MAX_RECONNECT_ATTEMPTS: 'maibot-ws-max-reconnect-attempts',
|
||||||
|
|
||||||
|
// 用户数据
|
||||||
|
// 注意:ACCESS_TOKEN 已弃用,现在使用 HttpOnly Cookie 存储认证信息
|
||||||
|
// 保留此常量仅用于向后兼容和清理旧数据
|
||||||
|
ACCESS_TOKEN: 'access-token',
|
||||||
|
COMPLETED_TOURS: 'maibot-completed-tours',
|
||||||
|
CHAT_USER_ID: 'maibot_webui_user_id',
|
||||||
|
CHAT_USER_NAME: 'maibot_webui_user_name',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 默认设置值
|
||||||
|
export const DEFAULT_SETTINGS = {
|
||||||
|
// 外观
|
||||||
|
theme: 'system' as 'light' | 'dark' | 'system',
|
||||||
|
accentColor: 'blue',
|
||||||
|
enableAnimations: true,
|
||||||
|
enableWavesBackground: true,
|
||||||
|
|
||||||
|
// 性能与存储
|
||||||
|
logCacheSize: 1000,
|
||||||
|
logAutoScroll: true,
|
||||||
|
logFontSize: 'xs' as 'xs' | 'sm' | 'base',
|
||||||
|
logLineSpacing: 4,
|
||||||
|
dataSyncInterval: 30, // 秒
|
||||||
|
wsReconnectInterval: 3000, // 毫秒
|
||||||
|
wsMaxReconnectAttempts: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置类型定义
|
||||||
|
export type Settings = typeof DEFAULT_SETTINGS
|
||||||
|
|
||||||
|
// 可导出的设置(不包含敏感信息)
|
||||||
|
export type ExportableSettings = Omit<Settings, never> & {
|
||||||
|
completedTours?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个设置值
|
||||||
|
*/
|
||||||
|
export function getSetting<K extends keyof Settings>(key: K): Settings[K] {
|
||||||
|
const storageKey = getStorageKey(key)
|
||||||
|
const stored = localStorage.getItem(storageKey)
|
||||||
|
|
||||||
|
if (stored === null) {
|
||||||
|
return DEFAULT_SETTINGS[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据默认值类型进行转换
|
||||||
|
const defaultValue = DEFAULT_SETTINGS[key]
|
||||||
|
|
||||||
|
if (typeof defaultValue === 'boolean') {
|
||||||
|
return (stored === 'true') as Settings[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof defaultValue === 'number') {
|
||||||
|
const num = parseFloat(stored)
|
||||||
|
return (isNaN(num) ? defaultValue : num) as Settings[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored as Settings[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单个值
|
||||||
|
*/
|
||||||
|
export function setSetting<K extends keyof Settings>(key: K, value: Settings[K]): void {
|
||||||
|
const storageKey = getStorageKey(key)
|
||||||
|
localStorage.setItem(storageKey, String(value))
|
||||||
|
|
||||||
|
// 触发自定义事件,通知其他组件设置已更新
|
||||||
|
window.dispatchEvent(new CustomEvent('maibot-settings-change', {
|
||||||
|
detail: { key, value }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有设置
|
||||||
|
*/
|
||||||
|
export function getAllSettings(): Settings {
|
||||||
|
return {
|
||||||
|
theme: getSetting('theme'),
|
||||||
|
accentColor: getSetting('accentColor'),
|
||||||
|
enableAnimations: getSetting('enableAnimations'),
|
||||||
|
enableWavesBackground: getSetting('enableWavesBackground'),
|
||||||
|
logCacheSize: getSetting('logCacheSize'),
|
||||||
|
logAutoScroll: getSetting('logAutoScroll'),
|
||||||
|
logFontSize: getSetting('logFontSize'),
|
||||||
|
logLineSpacing: getSetting('logLineSpacing'),
|
||||||
|
dataSyncInterval: getSetting('dataSyncInterval'),
|
||||||
|
wsReconnectInterval: getSetting('wsReconnectInterval'),
|
||||||
|
wsMaxReconnectAttempts: getSetting('wsMaxReconnectAttempts'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出设置(用于备份)
|
||||||
|
*/
|
||||||
|
export function exportSettings(): ExportableSettings {
|
||||||
|
const settings = getAllSettings()
|
||||||
|
|
||||||
|
// 添加已完成的引导
|
||||||
|
const completedToursStr = localStorage.getItem(STORAGE_KEYS.COMPLETED_TOURS)
|
||||||
|
const completedTours = completedToursStr ? JSON.parse(completedToursStr) : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
completedTours,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入设置
|
||||||
|
*/
|
||||||
|
export function importSettings(settings: Partial<ExportableSettings>): { success: boolean; imported: string[]; skipped: string[] } {
|
||||||
|
const imported: string[] = []
|
||||||
|
const skipped: string[] = []
|
||||||
|
|
||||||
|
// 验证并导入每个设置
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
if (key === 'completedTours') {
|
||||||
|
// 特殊处理已完成的引导
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.COMPLETED_TOURS, JSON.stringify(value))
|
||||||
|
imported.push('completedTours')
|
||||||
|
} else {
|
||||||
|
skipped.push('completedTours')
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key in DEFAULT_SETTINGS) {
|
||||||
|
const settingKey = key as keyof Settings
|
||||||
|
const defaultValue = DEFAULT_SETTINGS[settingKey]
|
||||||
|
|
||||||
|
// 类型验证
|
||||||
|
if (typeof value === typeof defaultValue) {
|
||||||
|
// 额外验证
|
||||||
|
if (settingKey === 'theme' && !['light', 'dark', 'system'].includes(value as string)) {
|
||||||
|
skipped.push(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (settingKey === 'logFontSize' && !['xs', 'sm', 'base'].includes(value as string)) {
|
||||||
|
skipped.push(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
setSetting(settingKey, value as Settings[typeof settingKey])
|
||||||
|
imported.push(key)
|
||||||
|
} else {
|
||||||
|
skipped.push(key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: imported.length > 0,
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置所有设置为默认值
|
||||||
|
*/
|
||||||
|
export function resetAllSettings(): void {
|
||||||
|
for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) {
|
||||||
|
setSetting(key, DEFAULT_SETTINGS[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除已完成的引导
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.COMPLETED_TOURS)
|
||||||
|
|
||||||
|
// 触发全局事件
|
||||||
|
window.dispatchEvent(new CustomEvent('maibot-settings-reset'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有本地缓存
|
||||||
|
* 注意:认证信息现在存储在 HttpOnly Cookie 中,不受此函数影响
|
||||||
|
*/
|
||||||
|
export function clearLocalCache(): { clearedKeys: string[]; preservedKeys: string[] } {
|
||||||
|
const clearedKeys: string[] = []
|
||||||
|
const preservedKeys: string[] = []
|
||||||
|
|
||||||
|
// 遍历所有 localStorage 项
|
||||||
|
const keysToRemove: string[] = []
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i)
|
||||||
|
if (key) {
|
||||||
|
if (key.startsWith('maibot') || key.startsWith('accent-color') || key === 'access-token') {
|
||||||
|
keysToRemove.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除需要清除的 key
|
||||||
|
for (const key of keysToRemove) {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
clearedKeys.push(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clearedKeys, preservedKeys }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本地存储使用情况
|
||||||
|
*/
|
||||||
|
export function getStorageUsage(): { used: number; items: number; details: { key: string; size: number }[] } {
|
||||||
|
let totalSize = 0
|
||||||
|
const details: { key: string; size: number }[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i)
|
||||||
|
if (key) {
|
||||||
|
const value = localStorage.getItem(key) || ''
|
||||||
|
const size = (key.length + value.length) * 2 // UTF-16 编码,每个字符 2 字节
|
||||||
|
totalSize += size
|
||||||
|
details.push({ key, size })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按大小排序
|
||||||
|
details.sort((a, b) => b.size - a.size)
|
||||||
|
|
||||||
|
return {
|
||||||
|
used: totalSize,
|
||||||
|
items: localStorage.length,
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化字节大小
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部辅助函数:获取 localStorage key
|
||||||
|
function getStorageKey(settingKey: keyof Settings): string {
|
||||||
|
const keyMap: Record<keyof Settings, string> = {
|
||||||
|
theme: STORAGE_KEYS.THEME,
|
||||||
|
accentColor: STORAGE_KEYS.ACCENT_COLOR,
|
||||||
|
enableAnimations: STORAGE_KEYS.ENABLE_ANIMATIONS,
|
||||||
|
enableWavesBackground: STORAGE_KEYS.ENABLE_WAVES_BACKGROUND,
|
||||||
|
logCacheSize: STORAGE_KEYS.LOG_CACHE_SIZE,
|
||||||
|
logAutoScroll: STORAGE_KEYS.LOG_AUTO_SCROLL,
|
||||||
|
logFontSize: STORAGE_KEYS.LOG_FONT_SIZE,
|
||||||
|
logLineSpacing: STORAGE_KEYS.LOG_LINE_SPACING,
|
||||||
|
dataSyncInterval: STORAGE_KEYS.DATA_SYNC_INTERVAL,
|
||||||
|
wsReconnectInterval: STORAGE_KEYS.WS_RECONNECT_INTERVAL,
|
||||||
|
wsMaxReconnectAttempts: STORAGE_KEYS.WS_MAX_RECONNECT_ATTEMPTS,
|
||||||
|
}
|
||||||
|
return keyMap[settingKey]
|
||||||
|
}
|
||||||
176
dashboard/src/lib/survey-api.ts
Normal file
176
dashboard/src/lib/survey-api.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* 问卷调查 API 客户端
|
||||||
|
* 用于与 Cloudflare Workers 问卷服务交互
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SurveySubmission,
|
||||||
|
StoredSubmission,
|
||||||
|
SurveyStats,
|
||||||
|
SurveySubmitResponse,
|
||||||
|
SurveyStatsResponse,
|
||||||
|
UserSubmissionsResponse,
|
||||||
|
QuestionAnswer
|
||||||
|
} from '@/types/survey'
|
||||||
|
|
||||||
|
// 配置统计服务 API 地址
|
||||||
|
const STATS_API_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成或获取用户ID
|
||||||
|
*/
|
||||||
|
export function getUserId(): string {
|
||||||
|
const storageKey = 'maibot_user_id'
|
||||||
|
let userId = localStorage.getItem(storageKey)
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
// 生成新的用户ID: fp_{fingerprint}_{timestamp}_{random}
|
||||||
|
const fingerprint = Math.random().toString(36).substring(2, 10)
|
||||||
|
const timestamp = Date.now().toString(36)
|
||||||
|
const random = Math.random().toString(36).substring(2, 10)
|
||||||
|
userId = `fp_${fingerprint}_${timestamp}_${random}`
|
||||||
|
localStorage.setItem(storageKey, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交问卷
|
||||||
|
*/
|
||||||
|
export async function submitSurvey(
|
||||||
|
surveyId: string,
|
||||||
|
surveyVersion: string,
|
||||||
|
answers: QuestionAnswer[],
|
||||||
|
options?: {
|
||||||
|
allowMultiple?: boolean
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
): Promise<SurveySubmitResponse> {
|
||||||
|
try {
|
||||||
|
const userId = options?.userId || getUserId()
|
||||||
|
|
||||||
|
const submission: SurveySubmission & { allowMultiple?: boolean } = {
|
||||||
|
surveyId,
|
||||||
|
surveyVersion,
|
||||||
|
userId,
|
||||||
|
answers,
|
||||||
|
submittedAt: new Date().toISOString(),
|
||||||
|
allowMultiple: options?.allowMultiple,
|
||||||
|
metadata: {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
language: navigator.language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/survey/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { success: false, error: '提交过于频繁,请稍后再试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
return { success: false, error: data.error || '你已经提交过这份问卷了' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: data.error || '提交失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
submissionId: data.submissionId,
|
||||||
|
message: data.message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting survey:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取问卷统计数据
|
||||||
|
*/
|
||||||
|
export async function getSurveyStats(surveyId: string): Promise<SurveyStatsResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/survey/stats/${surveyId}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: false, error: data.error || '获取统计数据失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: true, stats: data.stats as SurveyStats }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching survey stats:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户提交记录
|
||||||
|
*/
|
||||||
|
export async function getUserSubmissions(
|
||||||
|
surveyId?: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<UserSubmissionsResponse> {
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
const params = new URLSearchParams({ user_id: finalUserId })
|
||||||
|
|
||||||
|
if (surveyId) {
|
||||||
|
params.append('survey_id', surveyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/survey/submissions?${params}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: false, error: data.error || '获取提交记录失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: true, submissions: data.submissions as StoredSubmission[] }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user submissions:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已提交问卷
|
||||||
|
*/
|
||||||
|
export async function checkUserSubmission(
|
||||||
|
surveyId: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<{ success: boolean; hasSubmitted?: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
user_id: finalUserId,
|
||||||
|
survey_id: surveyId
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/survey/check?${params}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: false, error: data.error || '检查失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: true, hasSubmitted: data.hasSubmitted }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking submission:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
44
dashboard/src/lib/system-api.ts
Normal file
44
dashboard/src/lib/system-api.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { fetchWithAuth, getAuthHeaders } from './fetch-with-auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统控制 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重启麦麦主程序
|
||||||
|
*/
|
||||||
|
export async function restartMaiBot(): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/system/restart', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '重启失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查麦麦运行状态
|
||||||
|
*/
|
||||||
|
export async function getMaiBotStatus(): Promise<{
|
||||||
|
running: boolean
|
||||||
|
uptime: number
|
||||||
|
version: string
|
||||||
|
start_time: string
|
||||||
|
}> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/system/status', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取状态失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
15
dashboard/src/lib/theme-context.ts
Normal file
15
dashboard/src/lib/theme-context.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
export type ThemeProviderState = {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: 'system',
|
||||||
|
setTheme: () => null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||||
82
dashboard/src/lib/token-validator.ts
Normal file
82
dashboard/src/lib/token-validator.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Token 验证规则和状态
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TokenValidationRule {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
validate: (token: string) => boolean
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenValidationResult {
|
||||||
|
isValid: boolean
|
||||||
|
rules: Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
passed: boolean
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token 验证规则定义
|
||||||
|
export const TOKEN_VALIDATION_RULES: TokenValidationRule[] = [
|
||||||
|
{
|
||||||
|
id: 'minLength',
|
||||||
|
label: '长度至少 10 位',
|
||||||
|
description: 'Token 长度必须大于等于 10 个字符',
|
||||||
|
validate: (token: string) => token.length >= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hasUppercase',
|
||||||
|
label: '包含大写字母',
|
||||||
|
description: '至少包含一个大写字母 (A-Z)',
|
||||||
|
validate: (token: string) => /[A-Z]/.test(token),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hasLowercase',
|
||||||
|
label: '包含小写字母',
|
||||||
|
description: '至少包含一个小写字母 (a-z)',
|
||||||
|
validate: (token: string) => /[a-z]/.test(token),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hasSpecialChar',
|
||||||
|
label: '包含特殊符号',
|
||||||
|
description: '至少包含一个特殊符号 (!@#$%^&*()_+-=[]{}|;:,.<>?/)',
|
||||||
|
validate: (token: string) => /[!@#$%^&*()_+\-=[\]{}|;:,.<>?/]/.test(token),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Token 并返回详细结果
|
||||||
|
*/
|
||||||
|
export function validateToken(token: string): TokenValidationResult {
|
||||||
|
const rules = TOKEN_VALIDATION_RULES.map((rule) => ({
|
||||||
|
id: rule.id,
|
||||||
|
label: rule.label,
|
||||||
|
description: rule.description,
|
||||||
|
passed: rule.validate(token),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isValid = rules.every((rule) => rule.passed)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid,
|
||||||
|
rules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取验证失败的规则
|
||||||
|
*/
|
||||||
|
export function getFailedRules(token: string): string[] {
|
||||||
|
const result = validateToken(token)
|
||||||
|
return result.rules.filter((rule) => !rule.passed).map((rule) => rule.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Token 是否完全有效
|
||||||
|
*/
|
||||||
|
export function isTokenValid(token: string): boolean {
|
||||||
|
return validateToken(token).isValid
|
||||||
|
}
|
||||||
6
dashboard/src/lib/utils.ts
Normal file
6
dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
26
dashboard/src/lib/version.ts
Normal file
26
dashboard/src/lib/version.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* MaiBot Dashboard 版本管理
|
||||||
|
*
|
||||||
|
* 这是唯一需要修改版本号的地方
|
||||||
|
* 修改此处的版本号后,所有展示版本的地方都会自动更新
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const APP_VERSION = '0.12.2'
|
||||||
|
export const APP_NAME = 'MaiBot Dashboard'
|
||||||
|
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取版本信息
|
||||||
|
*/
|
||||||
|
export const getVersionInfo = () => ({
|
||||||
|
version: APP_VERSION,
|
||||||
|
name: APP_NAME,
|
||||||
|
fullName: APP_FULL_NAME,
|
||||||
|
buildDate: import.meta.env.VITE_BUILD_DATE || new Date().toISOString().split('T')[0],
|
||||||
|
buildEnv: import.meta.env.MODE,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化版本显示
|
||||||
|
*/
|
||||||
|
export const formatVersion = (prefix = 'v') => `${prefix}${APP_VERSION}`
|
||||||
Reference in New Issue
Block a user