Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

100
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,100 @@
<template>
<RouterView />
<ToastContainer />
<ConfirmContainer />
</template>
<script setup lang="ts">
import { onMounted, onErrorCaptured } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ToastContainer from '@/components/ToastContainer.vue'
import ConfirmContainer from '@/components/ConfirmContainer.vue'
import apiClient from '@/api/client'
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import { log } from '@/utils/logger'
const authStore = useAuthStore()
// 立即检查token,如果存在就设置到store中
const storedToken = apiClient.getToken()
if (storedToken) {
authStore.token = storedToken
}
// 全局错误处理器 - 只处理特定错误,避免完全吞掉所有错误
onErrorCaptured((error: Error) => {
log.error('Error captured in component', error)
// 对于非关键错误,不阻止传播
return true
})
// 统一的模块加载错误处理
let moduleLoadFailureCount = 0
if (typeof window !== 'undefined') {
// 处理未捕获的 Promise 拒绝
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason
// 只处理模块加载失败的情况
if (error?.message?.includes('Failed to fetch dynamically imported module')) {
event.preventDefault() // 阻止控制台显示这个特定错误
if (moduleLoadFailureCount < NETWORK_CONFIG.MODULE_LOAD_RETRY_LIMIT) {
moduleLoadFailureCount++
log.info(`模块加载失败,尝试刷新页面 (${moduleLoadFailureCount}/${NETWORK_CONFIG.MODULE_LOAD_RETRY_LIMIT})`)
window.location.reload()
} else {
// 超过最大重试次数,显示友好提示
alert('页面加载失败,请手动刷新浏览器。如问题持续,请清除浏览器缓存后重试。')
}
return
}
// 其他 Promise 错误记录日志
log.error('Unhandled promise rejection', event.reason)
})
// 处理全局错误
window.addEventListener('error', (event) => {
// 过滤掉常见的无害警告
const harmlessWarnings = [
'ResizeObserver loop completed with undelivered notifications',
'ResizeObserver loop limit exceeded'
]
const isHarmless = harmlessWarnings.some(warning =>
event.message?.includes(warning)
)
if (isHarmless) {
event.preventDefault()
return
}
// 记录其他错误
if (event.error) {
log.error('Global error', event.error)
} else {
log.warn('Global error event', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
})
}
})
}
onMounted(async () => {
// 延迟检查认证状态,让页面先加载
setTimeout(async () => {
try {
await authStore.checkAuth()
} catch (error) {
// 即使checkAuth失败,也不要做任何会导致退出的操作
log.warn('Auth check failed, but keeping session', error)
}
}, AUTH_CONFIG.TOKEN_REFRESH_INTERVAL)
})
</script>

177
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,177 @@
import apiClient from './client'
// API密钥管理相关接口定义
export interface AdminApiKey {
id: string // UUID
user_id: string // UUID
user_email?: string
username?: string
name?: string
key_display?: string // 脱敏后的密钥显示
is_active: boolean
is_standalone: boolean // 是否为独立余额Key
balance_used_usd?: number // 已使用余额仅独立Key
current_balance_usd?: number | null // 当前余额独立Key预付费模式null表示无限制
total_requests?: number
total_tokens?: number
total_cost_usd?: number
rate_limit?: number
allowed_providers?: string[] | null // 允许的提供商列表
allowed_api_formats?: string[] | null // 允许的 API 格式列表
allowed_models?: string[] | null // 允许的模型列表
auto_delete_on_expiry?: boolean // 过期后是否自动删除
last_used_at?: string
expires_at?: string
created_at: string
updated_at?: string
}
export interface CreateStandaloneApiKeyRequest {
name?: string
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number
expire_days?: number | null // null = 永不过期
initial_balance_usd: number // 初始余额,必须设置
auto_delete_on_expiry?: boolean // 过期后是否自动删除
}
export interface AdminApiKeysResponse {
api_keys: AdminApiKey[]
total: number
limit: number
skip: number
}
export interface ApiKeyToggleResponse {
id: string // UUID
is_active: boolean
message: string
}
// 管理员API密钥管理相关API
export const adminApi = {
// 获取所有独立余额Keys列表
async getAllApiKeys(params?: {
skip?: number
limit?: number
is_active?: boolean
}): Promise<AdminApiKeysResponse> {
const response = await apiClient.get<AdminApiKeysResponse>('/api/admin/api-keys', {
params: params
})
return response.data
},
// 创建独立余额Key
async createStandaloneApiKey(data: CreateStandaloneApiKeyRequest): Promise<AdminApiKey & { key: string }> {
const response = await apiClient.post<AdminApiKey & { key: string }>(
'/api/admin/api-keys',
data
)
return response.data
},
// 更新独立余额Key
async updateApiKey(keyId: string, data: Partial<CreateStandaloneApiKeyRequest>): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.put<AdminApiKey & { message: string }>(
`/api/admin/api-keys/${keyId}`,
data
)
return response.data
},
// 切换API密钥状态启用/禁用)
async toggleApiKey(keyId: string): Promise<ApiKeyToggleResponse> {
const response = await apiClient.patch<ApiKeyToggleResponse>(
`/api/admin/api-keys/${keyId}`
)
return response.data
},
// 删除API密钥
async deleteApiKey(keyId: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string}>(
`/api/admin/api-keys/${keyId}`
)
return response.data
},
// 为独立余额Key调整余额
async addApiKeyBalance(keyId: string, amountUsd: number): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.patch<AdminApiKey & { message: string }>(
`/api/admin/api-keys/${keyId}/balance`,
{ amount_usd: amountUsd }
)
return response.data
},
// 获取API密钥详情可选包含完整密钥
async getApiKeyDetail(keyId: string, includeKey: boolean = false): Promise<AdminApiKey & { key?: string }> {
const response = await apiClient.get<AdminApiKey & { key?: string }>(
`/api/admin/api-keys/${keyId}`,
{ params: { include_key: includeKey } }
)
return response.data
},
// 获取完整的API密钥用于复制- 便捷方法
async getFullApiKey(keyId: string): Promise<{ key: string }> {
const response = await apiClient.get<{ key: string }>(
`/api/admin/api-keys/${keyId}`,
{ params: { include_key: true } }
)
return response.data
},
// 系统配置相关
// 获取所有系统配置
async getAllSystemConfigs(): Promise<any[]> {
const response = await apiClient.get<any[]>('/api/admin/system/configs')
return response.data
},
// 获取特定系统配置
async getSystemConfig(key: string): Promise<{ key: string; value: any }> {
const response = await apiClient.get<{ key: string; value: any }>(
`/api/admin/system/configs/${key}`
)
return response.data
},
// 更新系统配置
async updateSystemConfig(
key: string,
value: any,
description?: string
): Promise<{ key: string; value: any; description?: string }> {
const response = await apiClient.put<{ key: string; value: any; description?: string }>(
`/api/admin/system/configs/${key}`,
{ value, description }
)
return response.data
},
// 删除系统配置
async deleteSystemConfig(key: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(
`/api/admin/system/configs/${key}`
)
return response.data
},
// 获取系统统计
async getSystemStats(): Promise<any> {
const response = await apiClient.get<any>('/api/admin/system/stats')
return response.data
},
// 获取可用的API格式列表
async getApiFormats(): Promise<{ formats: Array<{ value: string; label: string; default_path: string; aliases: string[] }> }> {
const response = await apiClient.get<{ formats: Array<{ value: string; label: string; default_path: string; aliases: string[] }> }>(
'/api/admin/system/api-formats'
)
return response.data
}
}

View File

@@ -0,0 +1,109 @@
import apiClient from './client'
export interface Announcement {
id: string // UUID
title: string
content: string // Markdown格式
type: 'info' | 'warning' | 'maintenance' | 'important'
priority: number
is_pinned: boolean
is_active: boolean
author: {
id: string // UUID
username: string
}
start_time?: string
end_time?: string
created_at: string
updated_at: string
is_read?: boolean
}
export interface AnnouncementListResponse {
items: Announcement[]
total: number
unread_count: number
}
export interface CreateAnnouncementRequest {
title: string
content: string
type?: 'info' | 'warning' | 'maintenance' | 'important'
priority?: number
is_pinned?: boolean
start_time?: string
end_time?: string
}
export interface UpdateAnnouncementRequest {
title?: string
content?: string
type?: string
priority?: number
is_active?: boolean
is_pinned?: boolean
start_time?: string
end_time?: string
}
export const announcementApi = {
// 获取公告列表
async getAnnouncements(params?: {
active_only?: boolean
unread_only?: boolean
limit?: number
offset?: number
}): Promise<AnnouncementListResponse> {
const response = await apiClient.get('/api/announcements', { params })
return response.data
},
// 获取当前有效的公告
async getActiveAnnouncements(): Promise<AnnouncementListResponse> {
const response = await apiClient.get('/api/announcements/active')
return response.data
},
// 获取单个公告
async getAnnouncement(id: string): Promise<Announcement> {
const response = await apiClient.get(`/api/announcements/${id}`)
return response.data
},
// 标记公告为已读
async markAsRead(id: string): Promise<{ message: string }> {
const response = await apiClient.patch(`/api/announcements/${id}/read-status`)
return response.data
},
// 标记所有公告为已读
async markAllAsRead(): Promise<{ message: string }> {
const response = await apiClient.post('/api/announcements/read-all')
return response.data
},
// 获取未读公告数量
async getUnreadCount(): Promise<{ unread_count: number }> {
const response = await apiClient.get('/api/announcements/users/me/unread-count')
return response.data
},
// 管理员方法
// 创建公告
async createAnnouncement(data: CreateAnnouncementRequest): Promise<{ id: string; title: string; message: string }> {
const response = await apiClient.post('/api/announcements', data)
return response.data
},
// 更新公告
async updateAnnouncement(id: string, data: UpdateAnnouncementRequest): Promise<{ message: string }> {
const response = await apiClient.put(`/api/announcements/${id}`, data)
return response.data
},
// 删除公告
async deleteAnnouncement(id: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/announcements/${id}`)
return response.data
}
}

91
frontend/src/api/audit.ts Normal file
View File

@@ -0,0 +1,91 @@
import apiClient from './client'
export interface AuditLog {
id: string
event_type: string
user_id?: number
description: string
ip_address?: string
status_code?: number
error_message?: string
metadata?: any
created_at: string
}
export interface PaginationMeta {
total: number
limit: number
offset: number
count: number
}
export interface AuditLogsResponse {
items: AuditLog[]
meta: PaginationMeta
filters?: Record<string, any>
}
export interface AuditFilters {
user_id?: string
event_type?: string
days?: number
limit?: number
offset?: number
}
function normalizeAuditResponse(data: any): AuditLogsResponse {
const items: AuditLog[] = data.items ?? data.logs ?? []
const meta: PaginationMeta = data.meta ?? {
total: data.total ?? items.length,
limit: data.limit ?? items.length,
offset: data.offset ?? 0,
count: data.count ?? items.length
}
return {
items,
meta,
filters: data.filters
}
}
export const auditApi = {
// 获取当前用户的活动日志
async getMyAuditLogs(filters?: {
event_type?: string
days?: number
limit?: number
offset?: number
}): Promise<AuditLogsResponse> {
const response = await apiClient.get('/api/monitoring/my-audit-logs', { params: filters })
return normalizeAuditResponse(response.data)
},
// 获取所有审计日志 (管理员)
async getAuditLogs(filters?: AuditFilters): Promise<AuditLogsResponse> {
const response = await apiClient.get('/api/admin/monitoring/audit-logs', { params: filters })
return normalizeAuditResponse(response.data)
},
// 获取可疑活动 (管理员)
async getSuspiciousActivities(hours: number = 24, limit: number = 100): Promise<{
activities: AuditLog[]
count: number
}> {
const response = await apiClient.get('/api/admin/monitoring/suspicious-activities', {
params: { hours, limit }
})
return response.data
},
// 分析用户行为 (管理员)
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
analysis: any
recommendations: string[]
}> {
const response = await apiClient.get(`/api/admin/monitoring/user-behavior/${userId}`, {
params: { days }
})
return response.data
}
}

90
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,90 @@
import apiClient from './client'
export interface LoginRequest {
email: string
password: string
}
export interface LoginResponse {
access_token: string
refresh_token?: string
token_type?: string
expires_in?: number
user_id?: string // UUID
email?: string
username?: string
role?: string
}
export interface UserPreferences {
theme?: 'light' | 'dark' | 'auto'
language?: string
notifications_enabled?: boolean
[key: string]: unknown // 允许扩展其他偏好设置
}
export interface UserStats {
total_requests?: number
total_cost?: number
last_request_at?: string
[key: string]: unknown // 允许扩展其他统计数据
}
export interface User {
id: string // UUID
username: string
email?: string
role: string // 'admin' or 'user'
is_active: boolean
quota_usd?: number | null
used_usd?: number
total_usd?: number
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
allowed_endpoints?: string[] | null // 允许使用的端点 ID 列表
allowed_models?: string[] | null // 允许使用的模型名称列表
created_at: string
last_login_at?: string
preferences?: UserPreferences
stats?: UserStats
}
export const authApi = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
apiClient.setToken(response.data.access_token)
// 后端暂时没有返回 refresh_token
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data
},
async logout() {
try {
// 调用后端登出接口,将 Token 加入黑名单
await apiClient.post('/api/auth/logout', {})
} catch (error) {
// 即使后端登出失败,也要清除本地认证信息
console.warn('后端登出失败,仅清除本地认证信息:', error)
} finally {
// 清除本地认证信息
apiClient.clearAuth()
}
},
async getCurrentUser(): Promise<User> {
const response = await apiClient.get<User>('/api/users/me')
return response.data
},
async refreshToken(refreshToken: string): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {
refresh_token: refreshToken
})
apiClient.setToken(response.data.access_token)
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data
}
}

158
frontend/src/api/cache.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* 缓存监控 API 客户端
*/
import api from './client'
export interface CacheStats {
scheduler: string
cache_reservation_ratio: number
affinity_stats: {
storage_type: string
total_affinities: number
active_affinities: number | string
cache_hits: number
cache_misses: number
cache_hit_rate: number
cache_invalidations: number
provider_switches: number
key_switches: number
config: {
default_ttl: number
}
}
}
export interface DynamicReservationConfig {
probe_phase_requests: number
probe_reservation: number
stable_min_reservation: number
stable_max_reservation: number
low_load_threshold: number
high_load_threshold: number
success_count_for_full_confidence: number
cooldown_hours_for_full_confidence: number
}
export interface CacheConfig {
cache_ttl_seconds: number
cache_reservation_ratio: number
dynamic_reservation?: {
enabled: boolean
config: DynamicReservationConfig
description: Record<string, string>
}
description: {
cache_ttl: string
cache_reservation_ratio: string
dynamic_reservation?: string
}
}
export interface UserAffinity {
affinity_key: string
user_api_key_name: string | null
user_api_key_prefix: string | null // 用户 API Key 脱敏显示前4...后4
is_standalone: boolean
user_id: string | null
username: string | null
email: string | null
provider_id: string
provider_name: string | null
endpoint_id: string
endpoint_api_format: string | null
endpoint_url: string | null
key_id: string
key_name: string | null
key_prefix: string | null // Provider Key 脱敏显示前4...后4
rate_multiplier: number
model_name: string | null // 模型名称(如 claude-haiku-4-5-20250514
model_display_name: string | null // 模型显示名称(如 Claude Haiku 4.5
api_format: string | null // API 格式 (claude/openai)
created_at: number
expire_at: number
request_count: number
}
export interface AffinityListResponse {
items: UserAffinity[]
total: number
matched_user_id?: string | null
}
export const cacheApi = {
/**
* 获取缓存统计信息
*/
async getStats(): Promise<CacheStats> {
const response = await api.get('/api/admin/monitoring/cache/stats')
return response.data.data
},
/**
* 获取缓存配置
*/
async getConfig(): Promise<CacheConfig> {
const response = await api.get('/api/admin/monitoring/cache/config')
return response.data.data
},
/**
* 查询用户缓存亲和性(现在返回该用户所有端点的亲和性列表)
*
* @param userIdentifier 用户标识符支持用户名、邮箱、User UUID、API Key ID
*/
async getUserAffinity(userIdentifier: string): Promise<UserAffinity[] | null> {
const response = await api.get(`/api/admin/monitoring/cache/affinity/${userIdentifier}`)
if (response.data.status === 'not_found') {
return null
}
return response.data.affinities
},
/**
* 清除用户缓存
*
* @param userIdentifier 用户标识符支持用户名、邮箱、User UUID、API Key ID
*/
async clearUserCache(userIdentifier: string): Promise<void> {
await api.delete(`/api/admin/monitoring/cache/users/${userIdentifier}`)
},
/**
* 清除所有缓存
*/
async clearAllCache(): Promise<{ count: number }> {
const response = await api.delete('/api/admin/monitoring/cache')
return response.data
},
/**
* 清除指定Provider的所有缓存
*/
async clearProviderCache(providerId: string): Promise<{ count: number; provider_id: string }> {
const response = await api.delete(`/api/admin/monitoring/cache/providers/${providerId}`)
return response.data
},
/**
* 获取缓存亲和性列表
*/
async listAffinities(keyword?: string): Promise<AffinityListResponse> {
const response = await api.get('/api/admin/monitoring/cache/affinities', {
params: keyword ? { keyword } : undefined
})
return response.data.data
}
}
// 导出便捷函数
export const {
getStats,
getConfig,
getUserAffinity,
clearUserCache,
clearAllCache,
clearProviderCache,
listAffinities
} = cacheApi

254
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,254 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import { log } from '@/utils/logger'
// 在开发环境下使用代理,生产环境使用环境变量
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
/**
* 判断请求是否为公共端点
*/
function isPublicEndpoint(url?: string, method?: string): boolean {
if (!url) return false
const isHealthCheck = url.includes('/health') &&
method?.toLowerCase() === 'get' &&
!url.includes('/api/admin')
return url.includes('/public') ||
url.includes('.json') ||
isHealthCheck
}
/**
* 判断是否为认证相关请求
*/
function isAuthRequest(url?: string): boolean {
return url?.includes('/auth/login') || url?.includes('/auth/refresh') || url?.includes('/auth/logout') || false
}
/**
* 判断是否为可刷新的认证错误
*/
function isRefreshableAuthError(errorDetail: string): boolean {
const nonRefreshableErrors = [
'用户不存在或已禁用',
'需要管理员权限',
'权限不足',
'用户已禁用',
]
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
}
class ApiClient {
private client: AxiosInstance
private token: string | null = null
private isRefreshing = false
private refreshPromise: Promise<AxiosResponse> | null = null
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: NETWORK_CONFIG.API_TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
})
this.setupInterceptors()
}
/**
* 配置请求和响应拦截器
*/
private setupInterceptors(): void {
// 请求拦截器
this.client.interceptors.request.use(
(config) => {
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
config.url?.includes('/api/')
if (requiresAuth) {
const token = this.getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
this.client.interceptors.response.use(
(response) => response,
async (error) => this.handleResponseError(error)
)
}
/**
* 处理响应错误
*/
private async handleResponseError(error: any): Promise<any> {
const originalRequest = error.config
// 请求被取消
if (axios.isCancel(error)) {
return Promise.reject(error)
}
// 网络错误或服务器不可达
if (!error.response) {
log.warn('Network error or server unreachable', error.message)
return Promise.reject(error)
}
// 认证请求错误,直接返回
if (isAuthRequest(originalRequest?.url)) {
return Promise.reject(error)
}
// 处理401错误
if (error.response?.status === 401) {
return this.handle401Error(error, originalRequest)
}
return Promise.reject(error)
}
/**
* 处理401认证错误
*/
private async handle401Error(error: any, originalRequest: any): Promise<any> {
// 如果不需要认证,直接返回错误
if (isPublicEndpoint(originalRequest?.url, originalRequest?.method)) {
return Promise.reject(error)
}
// 如果已经重试过,不再重试
if (originalRequest._retry) {
return Promise.reject(error)
}
const errorDetail = error.response?.data?.detail || ''
log.debug('Got 401 error, attempting token refresh', { errorDetail })
// 检查是否为业务相关的401错误
if (!isRefreshableAuthError(errorDetail)) {
log.info('401 error but not authentication issue, keeping session', { errorDetail })
return Promise.reject(error)
}
// 获取refresh token
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) {
log.info('No refresh token available, clearing invalid token')
this.clearAuth()
return Promise.reject(error)
}
// 标记为已重试
originalRequest._retry = true
originalRequest._retryCount = (originalRequest._retryCount || 0) + 1
// 超过最大重试次数
if (originalRequest._retryCount > AUTH_CONFIG.MAX_RETRY_COUNT) {
log.error('Max retry attempts reached')
return Promise.reject(error)
}
// 如果正在刷新,等待刷新完成
if (this.isRefreshing) {
try {
await this.refreshPromise
originalRequest.headers.Authorization = `Bearer ${this.getToken()}`
return this.client.request(originalRequest)
} catch (refreshError) {
return Promise.reject(error)
}
}
// 开始刷新token
return this.refreshTokenAndRetry(refreshToken, originalRequest, error)
}
/**
* 刷新token并重试原始请求
*/
private async refreshTokenAndRetry(
refreshToken: string,
originalRequest: any,
originalError: any
): Promise<any> {
this.isRefreshing = true
this.refreshPromise = this.refreshToken(refreshToken)
try {
const response = await this.refreshPromise
this.setToken(response.data.access_token)
localStorage.setItem('refresh_token', response.data.refresh_token)
this.isRefreshing = false
this.refreshPromise = null
// 重试原始请求
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`
return this.client.request(originalRequest)
} catch (refreshError: any) {
log.error('Token refresh failed', refreshError)
this.isRefreshing = false
this.refreshPromise = null
this.clearAuth()
return Promise.reject(originalError)
}
}
setToken(token: string): void {
this.token = token
localStorage.setItem('access_token', token)
}
getToken(): string | null {
if (!this.token) {
this.token = localStorage.getItem('access_token')
}
return this.token
}
clearAuth(): void {
this.token = null
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
async refreshToken(refreshToken: string): Promise<AxiosResponse> {
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
}
async request<T = any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.request<T>(config)
}
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.get<T>(url, config)
}
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.post<T>(url, data, config)
}
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.put<T>(url, data, config)
}
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.patch<T>(url, data, config)
}
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.delete<T>(url, config)
}
}
export default new ApiClient()

View File

@@ -0,0 +1,262 @@
import apiClient from './client'
export interface DashboardStat {
name: string
value: string
subValue?: string
change?: string
changeType?: 'increase' | 'decrease' | 'neutral'
extraBadge?: string
icon: string
}
export interface RecentRequest {
id: string // UUID
user: string
model: string
tokens: number
time: string
}
export interface ProviderStatus {
name: string
status: 'active' | 'inactive'
requests: number
}
// 系统健康指标(管理员专用)
export interface SystemHealth {
avg_response_time: number
error_rate: number
error_requests: number
fallback_count: number
total_requests: number
}
// 成本统计(管理员专用)
export interface CostStats {
total_cost: number
total_actual_cost: number
cost_savings: number
}
// 缓存统计
export interface CacheStats {
cache_creation_tokens: number
cache_read_tokens: number
cache_creation_cost?: number
cache_read_cost?: number
cache_hit_rate?: number
total_cache_tokens: number
}
// 用户统计(管理员专用)
export interface UserStats {
total: number
active: number
}
// Token 详细分类
export interface TokenBreakdown {
input: number
output: number
cache_creation: number
cache_read: number
}
export interface DashboardStatsResponse {
stats: DashboardStat[]
today?: {
requests: number
tokens: number
cost: number
actual_cost?: number
cache_creation_tokens?: number
cache_read_tokens?: number
}
api_keys?: {
total: number
active: number
}
tokens?: {
month: number
}
// 管理员专用字段
system_health?: SystemHealth
cost_stats?: CostStats
cache_stats?: CacheStats
users?: UserStats
token_breakdown?: TokenBreakdown
}
export interface RecentRequestsResponse {
requests: RecentRequest[]
}
export interface ProviderStatusResponse {
providers: ProviderStatus[]
}
export interface RequestDetail {
id: string // UUID
request_id: string
user: {
id: string // UUID
username: string
email: string
}
api_key: {
id: string // UUID
name: string
display: string
}
provider: string
api_format?: string
model: string
target_model?: string | null // 映射后的目标模型名
tokens: {
input: number
output: number
total: number
}
cost: {
input: number
output: number
total: number
}
// Additional token fields
input_tokens?: number
output_tokens?: number
total_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
// Additional cost fields
input_cost?: number
output_cost?: number
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
request_cost?: number // 按次计费费用
// Historical pricing fields (per 1M tokens)
input_price_per_1m?: number
output_price_per_1m?: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格
request_type: string
is_stream: boolean
status_code: number
error_message?: string
response_time_ms: number
created_at: string
request_headers?: Record<string, any>
request_body?: Record<string, any>
provider_request_headers?: Record<string, any>
response_headers?: Record<string, any>
response_body?: Record<string, any>
metadata?: Record<string, any>
// 阶梯计费信息
tiered_pricing?: {
total_input_context: number // 总输入上下文 (input + cache_read)
tier_index: number // 命中的阶梯索引 (0-based)
tier_count: number // 阶梯总数
source?: 'provider' | 'global' // 定价来源: 提供商或全局
current_tier: { // 当前命中的阶梯配置
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_read_price_per_1m: number
}>
}
tiers: Array<{ // 完整阶梯配置列表
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_read_price_per_1m: number
}>
}>
} | null
}
export interface ModelBreakdown {
model: string
requests: number
tokens: number
cost: number
}
export interface ModelSummary {
model: string
requests: number
tokens: number
cost: number
avg_response_time: number
cost_per_request: number
tokens_per_request: number
}
export interface DailyStat {
date: string // ISO date string
requests: number
tokens: number
cost: number
avg_response_time: number // in seconds
unique_models: number
unique_providers: number
model_breakdown: ModelBreakdown[]
}
export interface DailyStatsResponse {
daily_stats: DailyStat[]
model_summary: ModelSummary[]
period: {
start_date: string
end_date: string
days: number
}
}
export const dashboardApi = {
// 获取仪表盘统计数据
async getStats(): Promise<DashboardStatsResponse> {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats')
return response.data
},
// 获取最近的请求记录
async getRecentRequests(limit: number = 10): Promise<RecentRequest[]> {
const response = await apiClient.get<RecentRequestsResponse>('/api/dashboard/recent-requests', {
params: { limit }
})
return response.data.requests
},
// 获取提供商状态
async getProviderStatus(): Promise<ProviderStatus[]> {
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
return response.data.providers
},
// 获取请求详情
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
async getRequestDetail(requestId: string): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`)
return response.data
},
// 获取每日统计数据
async getDailyStats(days: number = 7): Promise<DailyStatsResponse> {
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
params: { days }
})
return response.data
}
}

View File

@@ -0,0 +1,57 @@
import client from '../client'
import type { AdaptiveStatsResponse } from './types'
/**
* 启用/禁用 Key 的自适应模式
*/
export async function toggleAdaptiveMode(
keyId: string,
data: {
enabled: boolean
fixed_limit?: number
}
): Promise<{
message: string
key_id: string
is_adaptive: boolean
max_concurrent: number | null
effective_limit: number | null
}> {
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/mode`, data)
return response.data
}
/**
* 设置 Key 的固定并发限制
*/
export async function setConcurrentLimit(
keyId: string,
limit: number
): Promise<{
message: string
key_id: string
is_adaptive: boolean
max_concurrent: number
previous_mode: string
}> {
const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/limit`, null, {
params: { limit }
})
return response.data
}
/**
* 获取 Key 的自适应统计
*/
export async function getAdaptiveStats(keyId: string): Promise<AdaptiveStatsResponse> {
const response = await client.get(`/api/admin/adaptive/keys/${keyId}/stats`)
return response.data
}
/**
* 重置 Key 的学习状态
*/
export async function resetAdaptiveLearning(keyId: string): Promise<{ message: string; key_id: string }> {
const response = await client.delete(`/api/admin/adaptive/keys/${keyId}/learning`)
return response.data
}

View File

@@ -0,0 +1,121 @@
/**
* 模型别名管理 API
*/
import client from '../client'
import type { ModelMapping, ModelMappingCreate, ModelMappingUpdate } from './types'
export interface ModelAlias {
id: string
alias: string
global_model_id: string
global_model_name: string | null
global_model_display_name: string | null
provider_id: string | null
provider_name: string | null
scope: 'global' | 'provider'
mapping_type: 'alias' | 'mapping'
is_active: boolean
created_at: string
updated_at: string
}
export interface CreateModelAliasRequest {
alias: string
global_model_id: string
provider_id?: string | null
mapping_type?: 'alias' | 'mapping'
is_active?: boolean
}
export interface UpdateModelAliasRequest {
alias?: string
global_model_id?: string
provider_id?: string | null
mapping_type?: 'alias' | 'mapping'
is_active?: boolean
}
function transformMapping(mapping: ModelMapping): ModelAlias {
return {
id: mapping.id,
alias: mapping.source_model,
global_model_id: mapping.target_global_model_id,
global_model_name: mapping.target_global_model_name,
global_model_display_name: mapping.target_global_model_display_name,
provider_id: mapping.provider_id ?? null,
provider_name: mapping.provider_name ?? null,
scope: mapping.scope,
mapping_type: mapping.mapping_type || 'alias',
is_active: mapping.is_active,
created_at: mapping.created_at,
updated_at: mapping.updated_at
}
}
/**
* 获取别名列表
*/
export async function getAliases(params?: {
provider_id?: string
global_model_id?: string
is_active?: boolean
skip?: number
limit?: number
}): Promise<ModelAlias[]> {
const response = await client.get('/api/admin/models/mappings', {
params: {
provider_id: params?.provider_id,
target_global_model_id: params?.global_model_id,
is_active: params?.is_active,
skip: params?.skip,
limit: params?.limit
}
})
return (response.data as ModelMapping[]).map(transformMapping)
}
/**
* 获取单个别名
*/
export async function getAlias(id: string): Promise<ModelAlias> {
const response = await client.get(`/api/admin/models/mappings/${id}`)
return transformMapping(response.data)
}
/**
* 创建别名
*/
export async function createAlias(data: CreateModelAliasRequest): Promise<ModelAlias> {
const payload: ModelMappingCreate = {
source_model: data.alias,
target_global_model_id: data.global_model_id,
provider_id: data.provider_id ?? null,
mapping_type: data.mapping_type ?? 'alias',
is_active: data.is_active ?? true
}
const response = await client.post('/api/admin/models/mappings', payload)
return transformMapping(response.data)
}
/**
* 更新别名
*/
export async function updateAlias(id: string, data: UpdateModelAliasRequest): Promise<ModelAlias> {
const payload: ModelMappingUpdate = {
source_model: data.alias,
target_global_model_id: data.global_model_id,
provider_id: data.provider_id ?? null,
mapping_type: data.mapping_type,
is_active: data.is_active
}
const response = await client.patch(`/api/admin/models/mappings/${id}`, payload)
return transformMapping(response.data)
}
/**
* 删除别名
*/
export async function deleteAlias(id: string): Promise<void> {
await client.delete(`/api/admin/models/mappings/${id}`)
}

View File

@@ -0,0 +1,78 @@
import client from '../client'
import type { ProviderEndpoint } from './types'
/**
* 获取指定 Provider 的所有 Endpoints
*/
export async function getProviderEndpoints(providerId: string): Promise<ProviderEndpoint[]> {
const response = await client.get(`/api/admin/endpoints/providers/${providerId}/endpoints`)
return response.data
}
/**
* 获取 Endpoint 详情
*/
export async function getEndpoint(endpointId: string): Promise<ProviderEndpoint> {
const response = await client.get(`/api/admin/endpoints/${endpointId}`)
return response.data
}
/**
* 为 Provider 创建新的 Endpoint
*/
export async function createEndpoint(
providerId: string,
data: {
provider_id: string
api_format: string
base_url: string
custom_path?: string
auth_type?: string
auth_header?: string
headers?: Record<string, string>
timeout?: number
max_retries?: number
priority?: number
weight?: number
max_concurrent?: number
rate_limit?: number
is_active?: boolean
config?: Record<string, any>
}
): Promise<ProviderEndpoint> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
return response.data
}
/**
* 更新 Endpoint
*/
export async function updateEndpoint(
endpointId: string,
data: Partial<{
base_url: string
custom_path: string
auth_type: string
auth_header: string
headers: Record<string, string>
timeout: number
max_retries: number
priority: number
weight: number
max_concurrent: number
rate_limit: number
is_active: boolean
config: Record<string, any>
}>
): Promise<ProviderEndpoint> {
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)
return response.data
}
/**
* 删除 Endpoint
*/
export async function deleteEndpoint(endpointId: string): Promise<{ message: string; deleted_keys_count: number }> {
const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
return response.data
}

View File

@@ -0,0 +1,85 @@
import client from '../client'
import type {
GlobalModelCreate,
GlobalModelUpdate,
GlobalModelResponse,
GlobalModelWithStats,
GlobalModelListResponse
} from './types'
/**
* 获取 GlobalModel 列表
*/
export async function getGlobalModels(params?: {
skip?: number
limit?: number
is_active?: boolean
search?: string
}): Promise<GlobalModelListResponse> {
const response = await client.get('/api/admin/models/global', { params })
return response.data
}
/**
* 获取单个 GlobalModel 详情
*/
export async function getGlobalModel(id: string): Promise<GlobalModelWithStats> {
const response = await client.get(`/api/admin/models/global/${id}`)
return response.data
}
/**
* 创建 GlobalModel
*/
export async function createGlobalModel(data: GlobalModelCreate): Promise<GlobalModelResponse> {
const response = await client.post('/api/admin/models/global', data)
return response.data
}
/**
* 更新 GlobalModel
*/
export async function updateGlobalModel(
id: string,
data: GlobalModelUpdate
): Promise<GlobalModelResponse> {
const response = await client.patch(`/api/admin/models/global/${id}`, data)
return response.data
}
/**
* 删除 GlobalModel
*/
export async function deleteGlobalModel(
id: string,
force: boolean = false
): Promise<void> {
await client.delete(`/api/admin/models/global/${id}`, { params: { force } })
}
/**
* 批量为 GlobalModel 添加关联提供商
*/
export async function batchAssignToProviders(
globalModelId: string,
data: {
provider_ids: string[]
create_models: boolean
}
): Promise<{
success: Array<{
provider_id: string
provider_name: string
model_id?: string
}>
errors: Array<{
provider_id: string
error: string
}>
}> {
const response = await client.post(
`/api/admin/models/global/${globalModelId}/assign-to-providers`,
data
)
return response.data
}

View File

@@ -0,0 +1,88 @@
import client from '../client'
import type {
HealthStatus,
HealthSummary,
EndpointStatusMonitorResponse,
PublicEndpointStatusMonitorResponse
} from './types'
/**
* 获取健康状态摘要
*/
export async function getHealthSummary(): Promise<HealthSummary> {
const response = await client.get('/api/admin/endpoints/health/summary')
return response.data
}
/**
* 获取 Endpoint 健康状态
*/
export async function getEndpointHealth(endpointId: string): Promise<HealthStatus> {
const response = await client.get(`/api/admin/endpoints/health/endpoint/${endpointId}`)
return response.data
}
/**
* 获取 Key 健康状态
*/
export async function getKeyHealth(keyId: string): Promise<HealthStatus> {
const response = await client.get(`/api/admin/endpoints/health/key/${keyId}`)
return response.data
}
/**
* 恢复Key健康状态一键恢复重置健康度 + 关闭熔断器 + 取消自动禁用)
*/
export async function recoverKeyHealth(keyId: string): Promise<{
message: string
details: {
health_score: number
circuit_breaker_open: boolean
is_active: boolean
}
}> {
const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`)
return response.data
}
/**
* 批量恢复所有熔断的Key健康状态
*/
export async function recoverAllKeysHealth(): Promise<{
message: string
recovered_count: number
recovered_keys: Array<{
key_id: string
key_name: string
endpoint_id: string
}>
}> {
const response = await client.patch('/api/admin/endpoints/health/keys')
return response.data
}
/**
* 获取按 API 格式聚合的健康监控时间线(管理员版,含 provider/key 数量)
*/
export async function getEndpointStatusMonitor(params?: {
lookback_hours?: number
per_format_limit?: number
}): Promise<EndpointStatusMonitorResponse> {
const response = await client.get('/api/admin/endpoints/health/api-formats', {
params
})
return response.data
}
/**
* 获取按 API 格式聚合的健康监控时间线(公开版,不含敏感信息)
*/
export async function getPublicEndpointStatusMonitor(params?: {
lookback_hours?: number
per_format_limit?: number
}): Promise<PublicEndpointStatusMonitorResponse> {
const response = await client.get('/api/public/health/api-formats', {
params
})
return response.data
}

View File

@@ -0,0 +1,9 @@
export * from './types'
export * from './providers'
export * from './endpoints'
export * from './keys'
export * from './health'
export * from './models'
export * from './aliases'
export * from './adaptive'
export * from './global-models'

View File

@@ -0,0 +1,132 @@
import client from '../client'
import type { EndpointAPIKey } from './types'
/**
* 能力定义类型
*/
export interface CapabilityDefinition {
name: string
display_name: string
description: string
match_mode: 'exclusive' | 'compatible'
config_mode?: 'user_configurable' | 'auto_detect' | 'request_param'
short_name?: string
}
/**
* 模型支持的能力响应类型
*/
export interface ModelCapabilitiesResponse {
model: string
global_model_id?: string
global_model_name?: string
supported_capabilities: string[]
capability_details: CapabilityDefinition[]
error?: string
}
/**
* 获取所有能力定义
*/
export async function getAllCapabilities(): Promise<CapabilityDefinition[]> {
const response = await client.get('/api/capabilities')
return response.data.capabilities
}
/**
* 获取用户可配置的能力列表
*/
export async function getUserConfigurableCapabilities(): Promise<CapabilityDefinition[]> {
const response = await client.get('/api/capabilities/user-configurable')
return response.data.capabilities
}
/**
* 获取指定模型支持的能力列表
*/
export async function getModelCapabilities(modelName: string): Promise<ModelCapabilitiesResponse> {
const response = await client.get(`/api/capabilities/model/${encodeURIComponent(modelName)}`)
return response.data
}
/**
* 获取 Endpoint 的所有 Keys
*/
export async function getEndpointKeys(endpointId: string): Promise<EndpointAPIKey[]> {
const response = await client.get(`/api/admin/endpoints/${endpointId}/keys`)
return response.data
}
/**
* 为 Endpoint 添加 Key
*/
export async function addEndpointKey(
endpointId: string,
data: {
endpoint_id: string
api_key: string
name: string // 密钥名称(必填)
rate_multiplier?: number // 成本倍率(默认 1.0
internal_priority?: number // Endpoint 内部优先级(数字越小越优先)
max_concurrent?: number // 最大并发数(留空=自适应模式)
rate_limit?: number
daily_limit?: number
monthly_limit?: number
cache_ttl_minutes?: number // 缓存 TTL分钟0=禁用
max_probe_interval_minutes?: number // 熔断探测间隔(分钟)
allowed_models?: string[] // 允许使用的模型列表
capabilities?: Record<string, boolean> // 能力标签配置
note?: string // 备注说明(可选)
}
): Promise<EndpointAPIKey> {
const response = await client.post(`/api/admin/endpoints/${endpointId}/keys`, data)
return response.data
}
/**
* 更新 Endpoint Key
*/
export async function updateEndpointKey(
keyId: string,
data: Partial<{
api_key: string
name: string // 密钥名称
rate_multiplier: number // 成本倍率
internal_priority: number // Endpoint 内部优先级(提供商优先模式,数字越小越优先)
global_priority: number // 全局 Key 优先级(全局 Key 优先模式,数字越小越优先)
max_concurrent: number // 最大并发数(留空=自适应模式)
rate_limit: number
daily_limit: number
monthly_limit: number
cache_ttl_minutes: number // 缓存 TTL分钟0=禁用
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
allowed_models: string[] | null // 允许使用的模型列表null 表示允许所有
capabilities: Record<string, boolean> | null // 能力标签配置
is_active: boolean
note: string // 备注说明
}>
): Promise<EndpointAPIKey> {
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
return response.data
}
/**
* 删除 Endpoint Key
*/
export async function deleteEndpointKey(keyId: string): Promise<{ message: string }> {
const response = await client.delete(`/api/admin/endpoints/keys/${keyId}`)
return response.data
}
/**
* 批量更新 Endpoint Keys 的优先级(用于拖动排序)
*/
export async function batchUpdateKeyPriority(
endpointId: string,
priorities: Array<{ key_id: string; internal_priority: number }>
): Promise<{ message: string; updated_count: number }> {
const response = await client.put(`/api/admin/endpoints/${endpointId}/keys/batch-priority`, {
priorities
})
return response.data
}

View File

@@ -0,0 +1,145 @@
import client from '../client'
import type {
Model,
ModelCreate,
ModelUpdate,
ModelCatalogResponse,
ProviderAvailableSourceModelsResponse,
UpdateModelMappingRequest,
UpdateModelMappingResponse,
DeleteModelMappingResponse
} from './types'
/**
* 获取 Provider 的所有模型
*/
export async function getProviderModels(
providerId: string,
params?: {
is_active?: boolean
skip?: number
limit?: number
}
): Promise<Model[]> {
const response = await client.get(`/api/admin/providers/${providerId}/models`, { params })
return response.data
}
/**
* 创建模型
*/
export async function createModel(
providerId: string,
data: ModelCreate
): Promise<Model> {
const response = await client.post(`/api/admin/providers/${providerId}/models`, data)
return response.data
}
/**
* 获取模型详情
*/
export async function getModel(
providerId: string,
modelId: string
): Promise<Model> {
const response = await client.get(`/api/admin/providers/${providerId}/models/${modelId}`)
return response.data
}
/**
* 更新模型
*/
export async function updateModel(
providerId: string,
modelId: string,
data: ModelUpdate
): Promise<Model> {
const response = await client.patch(`/api/admin/providers/${providerId}/models/${modelId}`, data)
return response.data
}
/**
* 删除模型
*/
export async function deleteModel(
providerId: string,
modelId: string
): Promise<{ message: string }> {
const response = await client.delete(`/api/admin/providers/${providerId}/models/${modelId}`)
return response.data
}
/**
* 批量创建模型
*/
export async function batchCreateModels(
providerId: string,
modelsData: ModelCreate[]
): Promise<Model[]> {
const response = await client.post(`/api/admin/providers/${providerId}/models/batch`, modelsData)
return response.data
}
/**
* 获取统一模型目录
*/
export async function getModelCatalog(): Promise<ModelCatalogResponse> {
const response = await client.get('/api/admin/models/catalog')
return response.data
}
/**
* 获取 Provider 支持的统一模型列表
*/
export async function getProviderAvailableSourceModels(
providerId: string
): Promise<ProviderAvailableSourceModelsResponse> {
const response = await client.get(`/api/admin/providers/${providerId}/available-source-models`)
return response.data
}
/**
* 更新目录中的模型映射
*/
export async function updateCatalogMapping(
mappingId: string,
data: UpdateModelMappingRequest
): Promise<UpdateModelMappingResponse> {
const response = await client.put(`/api/admin/models/catalog/mappings/${mappingId}`, data)
return response.data
}
/**
* 删除目录中的模型映射
*/
export async function deleteCatalogMapping(
mappingId: string
): Promise<DeleteModelMappingResponse> {
const response = await client.delete(`/api/admin/models/catalog/mappings/${mappingId}`)
return response.data
}
/**
* 批量为 Provider 关联 GlobalModels
*/
export async function batchAssignModelsToProvider(
providerId: string,
globalModelIds: string[]
): Promise<{
success: Array<{
global_model_id: string
global_model_name: string
model_id: string
}>
errors: Array<{
global_model_id: string
error: string
}>
}> {
const response = await client.post(
`/api/admin/providers/${providerId}/assign-global-models`,
{ global_model_ids: globalModelIds }
)
return response.data
}

View File

@@ -0,0 +1,60 @@
import client from '../client'
import type { ProviderWithEndpointsSummary } from './types'
/**
* 获取 Providers 摘要(包含 Endpoints 统计)
*/
export async function getProvidersSummary(): Promise<ProviderWithEndpointsSummary[]> {
const response = await client.get('/api/admin/providers/summary')
return response.data
}
/**
* 获取单个 Provider 的详细信息
*/
export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> {
const response = await client.get(`/api/admin/providers/${providerId}/summary`)
return response.data
}
/**
* 更新 Provider 基础配置
*/
export async function updateProvider(
providerId: string,
data: Partial<{
display_name: string
description: string
website: string
provider_priority: number
billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
monthly_quota_usd: number
quota_reset_day: number
quota_last_reset_at: string // 周期开始时间
quota_expires_at: string
rpm_limit: number | null
cache_ttl_minutes: number // 0表示不支持缓存>0表示支持缓存并设置TTL(分钟)
max_probe_interval_minutes: number
is_active: boolean
}>
): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch(`/api/admin/providers/${providerId}`, data)
return response.data
}
/**
* 创建 Provider
*/
export async function createProvider(data: any): Promise<any> {
const response = await client.post('/api/admin/providers/', data)
return response.data
}
/**
* 删除 Provider
*/
export async function deleteProvider(providerId: string): Promise<{ message: string }> {
const response = await client.delete(`/api/admin/providers/${providerId}`)
return response.data
}

View File

@@ -0,0 +1,553 @@
export interface ProviderEndpoint {
id: string
provider_id: string
provider_name: string
api_format: string
base_url: string
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
auth_type: string
auth_header?: string
headers?: Record<string, string>
timeout: number
max_retries: number
priority: number
weight: number
max_concurrent?: number
rate_limit?: number
health_score: number
consecutive_failures: number
last_failure_at?: string
is_active: boolean
config?: Record<string, any>
total_keys: number
active_keys: number
created_at: string
updated_at: string
}
export interface EndpointAPIKey {
id: string
endpoint_id: string
api_key_masked: string
api_key_plain?: string | null
name: string // 密钥名称(必填,用于识别)
rate_multiplier: number // 成本倍率(真实成本 = 表面成本 × 倍率)
internal_priority: number // Endpoint 内部优先级
global_priority?: number | null // 全局 Key 优先级
max_concurrent?: number
rate_limit?: number
daily_limit?: number
monthly_limit?: number
allowed_models?: string[] | null // 允许使用的模型列表null = 支持所有模型)
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m
// 缓存与熔断配置
cache_ttl_minutes: number // 缓存 TTL分钟0=禁用
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
health_score: number
consecutive_failures: number
last_failure_at?: string
request_count: number
success_count: number
error_count: number
success_rate: number
avg_response_time_ms: number
is_active: boolean
note?: string // 备注说明(可选)
last_used_at?: string
created_at: string
updated_at: string
// 自适应并发字段
is_adaptive?: boolean // 是否为自适应模式max_concurrent=NULL
effective_limit?: number // 当前有效限制(自适应使用学习值,固定使用配置值)
learned_max_concurrent?: number
// 滑动窗口利用率采样
utilization_samples?: Array<{ ts: number; util: number }> // 利用率采样窗口
last_probe_increase_at?: string // 上次探测性扩容时间
concurrent_429_count?: number
rpm_429_count?: number
last_429_at?: string
last_429_type?: string
// 熔断器字段(滑动窗口 + 半开模式)
circuit_breaker_open?: boolean
circuit_breaker_open_at?: string
next_probe_at?: string
half_open_until?: string
half_open_successes?: number
half_open_failures?: number
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
}
export interface EndpointHealthDetail {
api_format: string
health_score: number
is_active: boolean
}
export interface EndpointHealthEvent {
timestamp: string
status: 'success' | 'failed' | 'skipped' | 'started'
status_code?: number | null
latency_ms?: number | null
error_type?: string | null
error_message?: string | null
}
export interface EndpointStatusMonitor {
api_format: string
total_attempts: number
success_count: number
failed_count: number
skipped_count: number
success_rate: number
provider_count: number
key_count: number
last_event_at?: string | null
events: EndpointHealthEvent[]
timeline?: string[]
time_range_start?: string | null
time_range_end?: string | null
}
export interface EndpointStatusMonitorResponse {
generated_at: string
formats: EndpointStatusMonitor[]
}
// 公开版事件(不含敏感信息如 provider_id, key_id
export interface PublicHealthEvent {
timestamp: string
status: string
status_code?: number | null
latency_ms?: number | null
error_type?: string | null
}
// 公开版端点状态监控类型(返回 events前端复用 EndpointHealthTimeline 组件)
export interface PublicEndpointStatusMonitor {
api_format: string
api_path: string // 本站入口路径
total_attempts: number
success_count: number
failed_count: number
skipped_count: number
success_rate: number
last_event_at?: string | null
events: PublicHealthEvent[]
timeline?: string[]
time_range_start?: string | null
time_range_end?: string | null
}
export interface PublicEndpointStatusMonitorResponse {
generated_at: string
formats: PublicEndpointStatusMonitor[]
}
export interface ProviderWithEndpointsSummary {
id: string
name: string
display_name: string
description?: string
website?: string
provider_priority: number
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
monthly_quota_usd?: number
monthly_used_usd?: number
quota_reset_day?: number
quota_last_reset_at?: string // 当前周期开始时间
quota_expires_at?: string
rpm_limit?: number | null
rpm_used?: number
rpm_reset_at?: string
is_active: boolean
total_endpoints: number
active_endpoints: number
total_keys: number
active_keys: number
total_models: number
active_models: number
avg_health_score: number
unhealthy_endpoints: number
api_formats: string[]
endpoint_health_details: EndpointHealthDetail[]
created_at: string
updated_at: string
}
export interface HealthStatus {
endpoint_id?: string
endpoint_health_score?: number
endpoint_consecutive_failures?: number
endpoint_last_failure_at?: string
endpoint_is_active?: boolean
key_id?: string
key_health_score?: number
key_consecutive_failures?: number
key_last_failure_at?: string
key_is_active?: boolean
key_statistics?: Record<string, any>
}
export interface HealthSummary {
endpoints: {
total: number
active: number
unhealthy: number
}
keys: {
total: number
active: number
unhealthy: number
}
}
export interface ConcurrencyStatus {
endpoint_id?: string
endpoint_current_concurrency: number
endpoint_max_concurrent?: number
key_id?: string
key_current_concurrency: number
key_max_concurrent?: number
}
export interface Model {
id: string
provider_id: string
global_model_id?: string // 关联的 GlobalModel ID
provider_model_name: string // Provider 侧的模型名称(原 name
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
price_per_request?: number | null // 按次计费价格
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
supports_extended_thinking?: boolean | null
supports_image_generation?: boolean | null
// 有效值(合并 Model 和 GlobalModel 默认值后的结果)
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置
effective_input_price?: number | null
effective_output_price?: number | null
effective_price_per_request?: number | null // 有效按次计费价格
effective_supports_vision?: boolean | null
effective_supports_function_calling?: boolean | null
effective_supports_streaming?: boolean | null
effective_supports_extended_thinking?: boolean | null
effective_supports_image_generation?: boolean | null
is_active: boolean
is_available: boolean
created_at: string
updated_at: string
// GlobalModel 信息(从后端 join 获取)
global_model_name?: string
global_model_display_name?: string
}
export interface ModelCreate {
provider_model_name: string // Provider 侧的模型名称(原 name
global_model_id: string // 关联的 GlobalModel ID必填
// 计费配置(可选,为空时使用 GlobalModel 默认值)
price_per_request?: number // 按次计费价格
tiered_pricing?: TieredPricingConfig // 阶梯计费配置
// 能力配置(可选,为空时使用 GlobalModel 默认值)
supports_vision?: boolean
supports_function_calling?: boolean
supports_streaming?: boolean
supports_extended_thinking?: boolean
supports_image_generation?: boolean
is_active?: boolean
config?: Record<string, any>
}
export interface ModelUpdate {
provider_model_name?: string
global_model_id?: string
price_per_request?: number | null // 按次计费价格null 表示清空/使用默认值)
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
supports_vision?: boolean
supports_function_calling?: boolean
supports_streaming?: boolean
supports_extended_thinking?: boolean
supports_image_generation?: boolean
is_active?: boolean
is_available?: boolean
}
export interface ModelMapping {
id: string
source_model: string // 别名/源模型名
target_global_model_id: string // 目标 GlobalModel ID
target_global_model_name: string | null
target_global_model_display_name: string | null
provider_id: string | null
provider_name: string | null
scope: 'global' | 'provider'
mapping_type: 'alias' | 'mapping'
is_active: boolean
created_at: string
updated_at: string
}
export interface ModelCapabilities {
supports_vision: boolean
supports_function_calling: boolean
supports_streaming: boolean
[key: string]: boolean
}
export interface ProviderModelPriceInfo {
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
price_per_request?: number | null // 按次计费价格
}
export interface ModelPriceRange {
min_input: number | null
max_input: number | null
min_output: number | null
max_output: number | null
}
export interface ModelCatalogProviderDetail {
provider_id: string
provider_name: string
provider_display_name?: string | null
model_id?: string | null
target_model: string
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
cache_1h_creation_price_per_1m?: number | null // 1h 缓存创建价格
price_per_request?: number | null // 按次计费价格
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置(含继承)
tier_count?: number // 阶梯数量
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
is_active: boolean
mapping_id?: string | null
}
export interface ModelCatalogItem {
global_model_name: string // GlobalModel.name原 source_model
display_name: string // GlobalModel.display_name
description?: string | null // GlobalModel.description
aliases: string[] // 所有指向该 GlobalModel 的别名列表
providers: ModelCatalogProviderDetail[] // 支持该模型的 Provider 列表
price_range: ModelPriceRange // 价格区间
total_providers: number
capabilities: ModelCapabilities // 能力聚合
}
export interface ModelCatalogResponse {
models: ModelCatalogItem[]
total: number
}
export interface ProviderAvailableSourceModel {
global_model_name: string // GlobalModel.name原 source_model
display_name: string // GlobalModel.display_name
provider_model_name: string // Model.provider_model_nameProvider 侧的模型名)
has_alias: boolean // 是否有别名指向该 GlobalModel
aliases: string[] // 别名列表
model_id?: string | null // Model.id
price: ProviderModelPriceInfo
capabilities: ModelCapabilities
is_active: boolean
}
export interface ProviderAvailableSourceModelsResponse {
models: ProviderAvailableSourceModel[]
total: number
}
export interface BatchAssignProviderConfig {
provider_id: string
create_model?: boolean
model_config?: ModelCreate
model_id?: string
}
export interface BatchAssignModelMappingRequest {
global_model_id: string // 要分配的 GlobalModel ID原 source_model
providers: BatchAssignProviderConfig[]
}
export interface BatchAssignProviderResult {
provider_id: string
mapping_id?: string | null
created_model: boolean
model_id?: string | null
updated: boolean
}
export interface BatchAssignError {
provider_id: string
error: string
}
export interface BatchAssignModelMappingResponse {
success: boolean
created_mappings: BatchAssignProviderResult[]
errors: BatchAssignError[]
}
export interface ModelMappingCreate {
source_model: string // 源模型名或别名
target_global_model_id: string // 目标 GlobalModel ID
provider_id?: string | null
mapping_type?: 'alias' | 'mapping'
is_active?: boolean
}
export interface ModelMappingUpdate {
source_model?: string // 源模型名或别名
target_global_model_id?: string // 目标 GlobalModel ID
provider_id?: string | null
mapping_type?: 'alias' | 'mapping'
is_active?: boolean
}
export interface UpdateModelMappingRequest {
source_model?: string
target_global_model_id?: string
provider_id?: string | null
mapping_type?: 'alias' | 'mapping'
is_active?: boolean
}
export interface UpdateModelMappingResponse {
success: boolean
mapping_id: string
message?: string
}
export interface DeleteModelMappingResponse {
success: boolean
message?: string
}
export interface AdaptiveStatsResponse {
adaptive_mode: boolean
current_limit: number | null
learned_limit: number | null
concurrent_429_count: number
rpm_429_count: number
last_429_at: string | null
last_429_type: string | null
adjustment_count: number
recent_adjustments: Array<{
timestamp: string
old_limit: number
new_limit: number
reason: string
[key: string]: any
}>
}
// ========== 阶梯计费类型 ==========
/** 缓存时长定价配置 */
export interface CacheTTLPricing {
ttl_minutes: number
cache_creation_price_per_1m: number
}
/** 单个价格阶梯配置 */
export interface PricingTier {
up_to: number | null // null 表示无上限(最后一个阶梯)
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: CacheTTLPricing[]
}
/** 阶梯计费配置 */
export interface TieredPricingConfig {
tiers: PricingTier[]
}
// ========== GlobalModel 类型 ==========
export interface GlobalModelCreate {
name: string
display_name: string
description?: string
official_url?: string
icon_url?: string
// 按次计费配置(可选,与阶梯计费叠加)
default_price_per_request?: number
// 阶梯计费配置(必填,固定价格用单阶梯表示)
default_tiered_pricing: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[]
is_active?: boolean
}
export interface GlobalModelUpdate {
display_name?: string
description?: string
official_url?: string
icon_url?: string
is_active?: boolean
// 按次计费配置
default_price_per_request?: number | null // null 表示清空
// 阶梯计费配置
default_tiered_pricing?: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
}
export interface GlobalModelResponse {
id: string
name: string
display_name: string
description?: string
official_url?: string
icon_url?: string
is_active: boolean
// 按次计费配置
default_price_per_request?: number
// 阶梯计费配置(必填)
default_tiered_pricing: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
// 统计数据
provider_count?: number
alias_count?: number
usage_count?: number
created_at: string
updated_at?: string
}
export interface GlobalModelWithStats extends GlobalModelResponse {
total_models: number
total_providers: number
price_range: ModelPriceRange
}
export interface GlobalModelListResponse {
models: GlobalModelResponse[]
total: number
}

View File

@@ -0,0 +1,23 @@
/**
* GlobalModel API 客户端
* 统一导出,简化导入路径
*/
export * from './endpoints/global-models'
export type {
GlobalModelCreate,
GlobalModelUpdate,
GlobalModelResponse,
GlobalModelWithStats,
GlobalModelListResponse,
} from './endpoints/types'
// 重新导出为更简洁的函数名
export {
getGlobalModels as listGlobalModels,
getGlobalModel,
createGlobalModel,
updateGlobalModel,
deleteGlobalModel,
batchAssignToProviders,
} from './endpoints/global-models'

257
frontend/src/api/me.ts Normal file
View File

@@ -0,0 +1,257 @@
import apiClient from './client'
import type { ActivityHeatmap } from '@/types/activity'
export interface Profile {
id: string // UUID
email: string
username: string
role: string
is_active: boolean
quota_usd: number | null
used_usd: number
total_usd?: number // 累积消费总额
created_at: string
updated_at: string
last_login_at?: string
preferences?: UserPreferences
}
export interface UserPreferences {
avatar_url?: string
bio?: string
default_provider_id?: string // UUID
default_provider?: any
theme: string
language: string
timezone?: string
notifications?: {
email?: boolean
usage_alerts?: boolean
announcements?: boolean
}
}
// 提供商配置接口
export interface ProviderConfig {
provider_id: string
priority: number // 优先级(越高越优先)
weight: number // 负载均衡权重
enabled: boolean // 是否启用
}
// 使用记录接口
export interface UsageRecordDetail {
id: string
provider: string
model: string
input_tokens: number
output_tokens: number
total_tokens: number
cost: number // 官方费率
actual_cost?: number // 倍率消耗(仅管理员可见)
rate_multiplier?: number // 成本倍率(仅管理员可见)
response_time_ms?: number
is_stream: boolean
created_at: string
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
status_code: number
error_message?: string
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格
}
// 模型统计接口
export interface ModelSummary {
model: string
requests: number
input_tokens: number
output_tokens: number
total_tokens: number
total_cost_usd: number
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
}
// 使用统计响应接口
export interface UsageResponse {
total_requests: number
total_input_tokens: number
total_output_tokens: number
total_tokens: number
total_cost: number // 官方费率
total_actual_cost?: number // 倍率消耗(仅管理员可见)
avg_response_time: number
quota_usd: number | null
used_usd: number
summary_by_model: ModelSummary[]
records: UsageRecordDetail[]
activity_heatmap?: ActivityHeatmap | null
}
export interface ApiKey {
id: string // UUID
name: string
key?: string
key_display: string
is_active: boolean
last_used_at?: string
created_at: string
total_requests?: number
total_cost_usd?: number
allowed_providers?: ProviderConfig[]
force_capabilities?: Record<string, boolean> | null // 强制能力配置
}
// 不再需要 ProviderBinding 接口
export interface ChangePasswordRequest {
old_password: string
new_password: string
}
export const meApi = {
// 获取个人信息
async getProfile(): Promise<Profile> {
const response = await apiClient.get<Profile>('/api/users/me')
return response.data
},
// 更新个人信息
async updateProfile(data: {
email?: string
username?: string
}): Promise<{ message: string }> {
const response = await apiClient.put('/api/users/me', data)
return response.data
},
// 修改密码
async changePassword(data: ChangePasswordRequest): Promise<{ message: string }> {
const response = await apiClient.patch('/api/users/me/password', data)
return response.data
},
// API密钥管理
async getApiKeys(): Promise<ApiKey[]> {
const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys')
return response.data
},
async createApiKey(name: string): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', { name })
return response.data
},
async getApiKeyDetail(keyId: string, includeKey: boolean = false): Promise<ApiKey & { key?: string }> {
const response = await apiClient.get<ApiKey & { key?: string }>(
`/api/users/me/api-keys/${keyId}`,
{ params: { include_key: includeKey } }
)
return response.data
},
async getFullApiKey(keyId: string): Promise<{ key: string }> {
const response = await apiClient.get<{ key: string }>(
`/api/users/me/api-keys/${keyId}`,
{ params: { include_key: true } }
)
return response.data
},
async deleteApiKey(keyId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/users/me/api-keys/${keyId}`)
return response.data
},
async toggleApiKey(keyId: string): Promise<ApiKey> {
const response = await apiClient.patch<ApiKey>(`/api/users/me/api-keys/${keyId}`)
return response.data
},
// 使用统计
async getUsage(params?: {
start_date?: string
end_date?: string
}): Promise<UsageResponse> {
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
return response.data
},
// 获取活跃请求状态(用于轮询更新)
async getActiveRequests(ids?: string): Promise<{
requests: Array<{
id: string
status: string
input_tokens: number
output_tokens: number
cost: number
response_time_ms: number | null
}>
}> {
const params = ids ? { ids } : {}
const response = await apiClient.get('/api/users/me/usage/active', { params })
return response.data
},
// 获取可用的提供商
async getAvailableProviders(): Promise<any[]> {
const response = await apiClient.get('/api/users/me/providers')
return response.data
},
// 获取端点状态(不包含敏感信息)
async getEndpointStatus(): Promise<any[]> {
const response = await apiClient.get('/api/users/me/endpoint-status')
return response.data
},
// 偏好设置
async getPreferences(): Promise<UserPreferences> {
const response = await apiClient.get('/api/users/me/preferences')
return response.data
},
async updatePreferences(data: Partial<UserPreferences>): Promise<{ message: string }> {
const response = await apiClient.put('/api/users/me/preferences', data)
return response.data
},
// 提供商绑定管理相关方法已移除,改为直接从可用提供商中选择
// API密钥提供商关联
async updateApiKeyProviders(keyId: string, data: {
allowed_providers?: ProviderConfig[]
}): Promise<{ message: string }> {
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
return response.data
},
// API密钥能力配置
async updateApiKeyCapabilities(keyId: string, data: {
force_capabilities?: Record<string, boolean> | null
}): Promise<{ message: string; force_capabilities?: Record<string, boolean> | null }> {
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/capabilities`, data)
return response.data
},
// 模型能力配置
async getModelCapabilitySettings(): Promise<{
model_capability_settings: Record<string, Record<string, boolean>>
}> {
const response = await apiClient.get('/api/users/me/model-capabilities')
return response.data
},
async updateModelCapabilitySettings(data: {
model_capability_settings: Record<string, Record<string, boolean>> | null
}): Promise<{
message: string
model_capability_settings: Record<string, Record<string, boolean>> | null
}> {
const response = await apiClient.put('/api/users/me/model-capabilities', data)
return response.data
}
}

View File

@@ -0,0 +1,55 @@
/**
* 提供商策略管理 API 客户端
*/
import apiClient from './client';
const API_BASE = '/api/admin/provider-strategy';
export interface ProviderBillingConfig {
billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier';
monthly_quota_usd?: number;
quota_reset_day?: number;
quota_last_reset_at?: string; // 当前周期开始时间
quota_expires_at?: string;
rpm_limit?: number | null;
cache_ttl_minutes?: number; // 0表示不支持缓存>0表示支持缓存并设置TTL(分钟)
provider_priority?: number;
}
/**
* 更新提供商计费配置
*/
export async function updateProviderBilling(
providerId: string,
config: ProviderBillingConfig
) {
const response = await apiClient.put(`${API_BASE}/providers/${providerId}/billing`, config);
return response.data;
}
/**
* 获取提供商使用统计
*/
export async function getProviderStats(providerId: string, hours: number = 24) {
const response = await apiClient.get(`${API_BASE}/providers/${providerId}/stats`, {
params: { hours }
});
return response.data;
}
/**
* 重置提供商月卡额度
*/
export async function resetProviderQuota(providerId: string) {
const response = await apiClient.delete(`${API_BASE}/providers/${providerId}/quota`);
return response.data;
}
/**
* 获取所有可用的负载均衡策略
*/
export async function listAvailableStrategies() {
const response = await apiClient.get(`${API_BASE}/strategies`);
return response.data;
}

View File

@@ -0,0 +1,44 @@
/**
* Public Models API - 普通用户可访问的模型列表
*/
import client from './client'
import type { TieredPricingConfig } from './endpoints/types'
export interface PublicGlobalModel {
id: string
name: string
display_name: string | null
description: string | null
icon_url: string | null
is_active: boolean
// 阶梯计费配置
default_tiered_pricing: TieredPricingConfig
default_price_per_request: number | null // 按次计费价格
// 能力
default_supports_vision: boolean
default_supports_function_calling: boolean
default_supports_streaming: boolean
default_supports_extended_thinking: boolean
default_supports_image_generation: boolean
// Key 能力支持
supported_capabilities: string[] | null
}
export interface PublicGlobalModelListResponse {
models: PublicGlobalModel[]
total: number
}
/**
* 获取公开的 GlobalModel 列表(普通用户可访问)
*/
export async function getPublicGlobalModels(params?: {
skip?: number
limit?: number
is_active?: boolean
search?: string
}): Promise<PublicGlobalModelListResponse> {
const response = await client.get('/api/public/global-models', { params })
return response.data
}

View File

@@ -0,0 +1,69 @@
import apiClient from './client'
export interface CandidateRecord {
id: string
request_id: string
candidate_index: number
retry_index: number
provider_id?: string
provider_name?: string
provider_website?: string // Provider 官网
endpoint_id?: string
endpoint_name?: string // 端点显示名称api_format
key_id?: string
key_name?: string // 密钥名称
key_preview?: string // 密钥脱敏预览(如 sk-***abc
key_capabilities?: Record<string, boolean> | null // Key 支持的能力
required_capabilities?: Record<string, boolean> | null // 请求实际需要的能力标签
status: 'pending' | 'streaming' | 'success' | 'failed' | 'skipped'
skip_reason?: string
is_cached: boolean
// 执行结果字段
status_code?: number
error_type?: string
error_message?: string
latency_ms?: number
concurrent_requests?: number
extra_data?: Record<string, any>
created_at: string
started_at?: string
finished_at?: string
}
export interface RequestTrace {
request_id: string
total_candidates: number
final_status: 'success' | 'failed' | 'streaming' | 'pending'
total_latency_ms: number
candidates: CandidateRecord[]
}
export interface ProviderStats {
total_attempts: number
success_count: number
failed_count: number
skipped_count: number
pending_count: number
available_count: number
failure_rate: number
}
export const requestTraceApi = {
/**
* 获取特定请求的完整追踪信息
*/
async getRequestTrace(requestId: string): Promise<RequestTrace> {
const response = await apiClient.get<RequestTrace>(`/api/admin/monitoring/trace/${requestId}`)
return response.data
},
/**
* 获取某个 Provider 的失败率统计
*/
async getProviderStats(providerId: string, limit: number = 100): Promise<ProviderStats> {
const response = await apiClient.get<ProviderStats>(`/api/admin/monitoring/trace/stats/provider/${providerId}`, {
params: { limit }
})
return response.data
}
}

View File

@@ -0,0 +1,83 @@
/**
* IP 安全管理 API
*/
import apiClient from './client'
export interface IPBlacklistEntry {
ip_address: string
reason: string
ttl?: number
}
export interface IPWhitelistEntry {
ip_address: string
}
export interface BlacklistStats {
available: boolean
total: number
error?: string
}
export interface WhitelistResponse {
whitelist: string[]
total: number
}
/**
* IP 黑名单管理
*/
export const blacklistApi = {
/**
* 添加 IP 到黑名单
*/
async add(data: IPBlacklistEntry) {
const response = await apiClient.post('/api/admin/security/ip/blacklist', data)
return response.data
},
/**
* 从黑名单移除 IP
*/
async remove(ip_address: string) {
const response = await apiClient.delete(`/api/admin/security/ip/blacklist/${encodeURIComponent(ip_address)}`)
return response.data
},
/**
* 获取黑名单统计
*/
async getStats(): Promise<BlacklistStats> {
const response = await apiClient.get('/api/admin/security/ip/blacklist/stats')
return response.data
}
}
/**
* IP 白名单管理
*/
export const whitelistApi = {
/**
* 添加 IP 到白名单
*/
async add(data: IPWhitelistEntry) {
const response = await apiClient.post('/api/admin/security/ip/whitelist', data)
return response.data
},
/**
* 从白名单移除 IP
*/
async remove(ip_address: string) {
const response = await apiClient.delete(`/api/admin/security/ip/whitelist/${encodeURIComponent(ip_address)}`)
return response.data
},
/**
* 获取白名单列表
*/
async getList(): Promise<WhitelistResponse> {
const response = await apiClient.get('/api/admin/security/ip/whitelist')
return response.data
}
}

202
frontend/src/api/usage.ts Normal file
View File

@@ -0,0 +1,202 @@
import apiClient from './client'
import { cachedRequest } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity'
export interface UsageRecord {
id: string // UUID
user_id: string // UUID
username?: string
provider_id?: string // UUID
provider_name?: string
model: string
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
total_tokens: number
cost?: number
response_time?: number
created_at: string
has_fallback?: boolean // 🆕 是否发生了 fallback
}
export interface UsageStats {
total_requests: number
total_tokens: number
total_cost: number
total_actual_cost?: number
avg_response_time: number
today?: {
requests: number
tokens: number
cost: number
}
activity_heatmap?: ActivityHeatmap | null
}
export interface UsageByModel {
model: string
request_count: number
total_tokens: number
total_cost: number
avg_response_time?: number
}
export interface UsageByUser {
user_id: string // UUID
email: string
username: string
request_count: number
total_tokens: number
total_cost: number
}
export interface UsageByProvider {
provider_id: string
provider: string
request_count: number
total_tokens: number
total_cost: number
actual_cost: number
avg_response_time_ms: number
success_rate: number
error_count: number
}
export interface UsageByApiFormat {
api_format: string
request_count: number
total_tokens: number
total_cost: number
actual_cost: number
avg_response_time_ms: number
}
export interface UsageFilters {
user_id?: string // UUID
provider_id?: string // UUID
model?: string
start_date?: string
end_date?: string
page?: number
page_size?: number
}
export const usageApi = {
async getUsageRecords(filters?: UsageFilters): Promise<{
records: UsageRecord[]
total: number
page: number
page_size: number
}> {
const response = await apiClient.get('/api/usage', { params: filters })
return response.data
},
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
// 为统计数据添加30秒缓存
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}`
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', { params: filters })
return response.data
},
30000 // 30秒缓存
)
},
/**
* Get usage aggregation by dimension (RESTful API)
* @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format'
* @param filters Optional filters
*/
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
groupBy: 'model' | 'user' | 'provider' | 'api_format',
filters?: UsageFilters & { limit?: number }
): Promise<T> {
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}`
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
params: { group_by: groupBy, ...filters }
})
return response.data
},
30000 // 30秒缓存
)
},
// Shorthand methods using getUsageAggregation
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> {
return this.getUsageAggregation<UsageByModel[]>('model', filters)
},
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> {
return this.getUsageAggregation<UsageByUser[]>('user', filters)
},
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> {
return this.getUsageAggregation<UsageByProvider[]>('provider', filters)
},
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> {
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters)
},
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
records: UsageRecord[]
stats: UsageStats
}> {
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
return response.data
},
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
const response = await apiClient.get('/api/usage/export', {
params: { ...filters, format },
responseType: 'blob'
})
return response.data
},
async getAllUsageRecords(params?: {
start_date?: string
end_date?: string
user_id?: string // UUID
username?: string
model?: string
provider?: string
status?: string // 'stream' | 'standard' | 'error'
limit?: number
offset?: number
}): Promise<{
records: any[]
total: number
limit: number
offset: number
}> {
const response = await apiClient.get('/api/admin/usage/records', { params })
return response.data
},
/**
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
* @param ids 可选,逗号分隔的请求 ID 列表
*/
async getActiveRequests(ids?: string[]): Promise<{
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed'
input_tokens: number
output_tokens: number
cost: number
response_time_ms: number | null
}>
}> {
const params = ids?.length ? { ids: ids.join(',') } : {}
const response = await apiClient.get('/api/admin/usage/active', { params })
return response.data
}
}

106
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,106 @@
import apiClient from './client'
export interface User {
id: string // UUID
username: string
email: string
role: 'admin' | 'user'
is_active: boolean
quota_usd: number | null
used_usd: number
total_usd: number
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
allowed_endpoints: string[] | null // 允许使用的端点 ID 列表
allowed_models: string[] | null // 允许使用的模型名称列表
created_at: string
updated_at?: string
}
export interface CreateUserRequest {
username: string
password: string
email: string
role?: 'admin' | 'user'
quota_usd?: number | null
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_models?: string[] | null
}
export interface UpdateUserRequest {
email?: string
is_active?: boolean
role?: 'admin' | 'user'
quota_usd?: number | null
password?: string
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_models?: string[] | null
}
export interface ApiKey {
id: string // UUID
key?: string // 完整的 key只在创建时返回
key_display?: string // 脱敏后的密钥显示
name?: string
created_at: string
last_used_at?: string
expires_at?: string // 过期时间
is_active: boolean
is_standalone: boolean // 是否为独立余额Key
balance_used_usd?: number // 已使用余额仅独立Key
current_balance_usd?: number | null // 当前余额独立Key预付费模式null表示无限制
rate_limit?: number // 速率限制(请求/分钟)
total_requests?: number // 总请求数
total_cost_usd?: number // 总费用
}
export const usersApi = {
async getAllUsers(): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/admin/users')
return response.data
},
async getUser(userId: string): Promise<User> {
const response = await apiClient.get<User>(`/api/admin/users/${userId}`)
return response.data
},
async createUser(user: CreateUserRequest): Promise<User> {
const response = await apiClient.post<User>('/api/admin/users', user)
return response.data
},
async updateUser(userId: string, updates: UpdateUserRequest): Promise<User> {
const response = await apiClient.put<User>(`/api/admin/users/${userId}`, updates)
return response.data
},
async deleteUser(userId: string): Promise<void> {
await apiClient.delete(`/api/admin/users/${userId}`)
},
async getUserApiKeys(userId: string): Promise<ApiKey[]> {
const response = await apiClient.get<{ api_keys: ApiKey[] }>(`/api/admin/users/${userId}/api-keys`)
return response.data.api_keys
},
async createApiKey(userId: string, name?: string): Promise<ApiKey & { key: string }> {
const response = await apiClient.post<ApiKey & { key: string }>(`/api/admin/users/${userId}/api-keys`, { name })
return response.data
},
async deleteApiKey(userId: string, keyId: string): Promise<void> {
await apiClient.delete(`/api/admin/users/${userId}/api-keys/${keyId}`)
},
async resetUserQuota(userId: string): Promise<void> {
await apiClient.patch(`/api/admin/users/${userId}/quota`)
},
// 管理员统计
async getUsageStats(): Promise<any> {
const response = await apiClient.get('/api/admin/usage/stats')
return response.data
}
}

View File

@@ -0,0 +1,412 @@
<template>
<div class="line-by-line-logo" :style="{ width: `${size}px`, height: `${size}px` }">
<svg
:viewBox="viewBox"
class="logo-svg"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<!-- Metallic gradient -->
<linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" :stop-color="metallicColors.dark" />
<stop offset="25%" :stop-color="metallicColors.base" />
<stop offset="50%" :stop-color="metallicColors.light" />
<stop offset="75%" :stop-color="metallicColors.base" />
<stop offset="100%" :stop-color="metallicColors.dark" />
</linearGradient>
</defs>
<!-- Layer 0: Ghost Tracks (Always visible, faint) -->
<g class="ghost-layer">
<path
v-for="(path, index) in linePaths"
:key="`ghost-${index}`"
:d="path"
class="ghost-path"
fill="none"
:stroke="currentColors.primary"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Layer 1: Fill (fade in/out) -->
<path
class="fill-path"
:d="fullPath"
:fill="`url(#${gradientId})`"
fill-rule="evenodd"
:style="fillStyle"
/>
<!-- Layer 2: Animated lines -->
<g class="lines-layer">
<path
v-for="(path, index) in linePaths"
:key="`line-${index}`"
:ref="(el) => setPathRef(el as SVGPathElement, index)"
:d="path"
class="line-path"
fill="none"
:stroke="currentColors.primary"
:stroke-width="strokeWidth"
:style="getLineStyle(index)"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { AETHER_SVG_VIEWBOX, AETHER_LINE_PATHS, AETHER_FULL_PATH } from '@/constants/logoPaths'
// Animation phases
type AnimationPhase = 'idle' | 'drawOutline' | 'fillFadeIn' | 'hold' | 'fillFadeOut' | 'eraseOutline'
// Color scheme type
interface ColorScheme {
primary: string
secondary: string
}
// Constants
const LINE_COUNT = AETHER_LINE_PATHS.length
const DEFAULT_PATH_LENGTH = 3000
// Light mode color schemes
const LIGHT_MODE_SCHEMES: ColorScheme[] = [
{ primary: '#9a5a42', secondary: '#c4866a' },
{ primary: '#8b4557', secondary: '#b87a8a' },
{ primary: '#996b2e', secondary: '#c49a5c' },
{ primary: '#7a5c3a', secondary: '#a8896a' },
{ primary: '#6b4d82', secondary: '#9a7eb5' },
{ primary: '#2d6a7a', secondary: '#5a9aaa' },
{ primary: '#4a6b3a', secondary: '#7a9a6a' },
{ primary: '#8a5a5a', secondary: '#b88a8a' },
{ primary: '#5a6a7a', secondary: '#8a9aaa' },
{ primary: '#6a5a4a', secondary: '#9a8a7a' },
{ primary: '#7a4a5a', secondary: '#aa7a8a' },
{ primary: '#4a5a6a', secondary: '#7a8a9a' },
]
// Dark mode color schemes
const DARK_MODE_SCHEMES: ColorScheme[] = [
{ primary: '#f59e0b', secondary: '#fcd34d' },
{ primary: '#ec4899', secondary: '#f9a8d4' },
{ primary: '#22d3ee', secondary: '#a5f3fc' },
{ primary: '#a855f7', secondary: '#d8b4fe' },
{ primary: '#4ade80', secondary: '#bbf7d0' },
{ primary: '#f472b6', secondary: '#fbcfe8' },
{ primary: '#38bdf8', secondary: '#bae6fd' },
{ primary: '#fb923c', secondary: '#fed7aa' },
{ primary: '#a78bfa', secondary: '#ddd6fe' },
{ primary: '#2dd4bf', secondary: '#99f6e4' },
{ primary: '#facc15', secondary: '#fef08a' },
{ primary: '#e879f9', secondary: '#f5d0fe' },
]
const props = withDefaults(
defineProps<{
size?: number
lineDelay?: number
strokeDuration?: number
fillDuration?: number
autoStart?: boolean
loop?: boolean
loopPause?: number
outlineColor?: string
gradientColor?: string
strokeWidth?: number
cycleColors?: boolean
isDark?: boolean
}>(),
{
size: 400,
lineDelay: 60,
strokeDuration: 1200,
fillDuration: 800,
autoStart: true,
loop: true,
loopPause: 600,
outlineColor: '#cc785c',
gradientColor: '#e8a882',
strokeWidth: 2.5,
cycleColors: false,
isDark: false
}
)
const emit = defineEmits<{
(e: 'animationComplete'): void
(e: 'phaseChange', phase: AnimationPhase): void
(e: 'colorChange', colors: ColorScheme): void
}>()
// Unique ID for gradient
const gradientId = `aether-gradient-${Math.random().toString(36).slice(2, 9)}`
const viewBox = AETHER_SVG_VIEWBOX
const linePaths = AETHER_LINE_PATHS
const fullPath = AETHER_FULL_PATH
// Path refs and lengths
const pathRefs = ref<(SVGPathElement | null)[]>(new Array(LINE_COUNT).fill(null))
const pathLengths = ref<number[]>(new Array(LINE_COUNT).fill(DEFAULT_PATH_LENGTH))
// Animation states
const lineDrawn = ref<boolean[]>(new Array(LINE_COUNT).fill(false))
const isFilled = ref(false)
const currentPhase = ref<AnimationPhase>('idle')
const isAnimating = ref(false)
// Timer cleanup
let animationAborted = false
let startTimeoutId: ReturnType<typeof setTimeout> | null = null
let hasStartedOnce = false
// Color cycling state
const colorIndex = ref(0)
// Computed
const activeSchemes = computed(() => props.isDark ? DARK_MODE_SCHEMES : LIGHT_MODE_SCHEMES)
const currentColors = computed<ColorScheme>(() => {
if (props.cycleColors) {
return activeSchemes.value[colorIndex.value % activeSchemes.value.length]
}
return { primary: props.outlineColor, secondary: props.gradientColor }
})
const metallicColors = computed(() => ({
dark: adjustColor(currentColors.value.primary, -20),
base: currentColors.value.primary,
light: currentColors.value.secondary,
highlight: adjustColor(currentColors.value.secondary, 30)
}))
// Fill style with fade transition
const fillStyle = computed(() => ({
opacity: isFilled.value ? 0.85 : 0,
transition: `opacity ${props.fillDuration}ms ease-in-out`
}))
// Helper functions
function adjustColor(hex: string, amount: number): string {
const num = parseInt(hex.replace('#', ''), 16)
const r = Math.min(255, Math.max(0, (num >> 16) + amount))
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + amount))
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + amount))
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
}
const setPathRef = (el: SVGPathElement | null, index: number) => {
pathRefs.value[index] = el
}
const calculatePathLengths = () => {
pathRefs.value.forEach((path, index) => {
if (path) {
try {
pathLengths.value[index] = path.getTotalLength()
} catch {
pathLengths.value[index] = DEFAULT_PATH_LENGTH
}
}
})
}
// Line style with stroke drawing animation
const getLineStyle = (index: number) => {
const pathLength = pathLengths.value[index]
const isDrawn = lineDrawn.value[index]
const phase = currentPhase.value
// Only enable transition during actual draw/erase phases
let transition = 'none'
if (phase === 'drawOutline' || phase === 'eraseOutline') {
transition = `stroke-dashoffset ${props.strokeDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`
}
return {
strokeDasharray: pathLength,
strokeDashoffset: isDrawn ? 0 : pathLength,
transition
}
}
// Abortable wait
const wait = (ms: number) => new Promise<void>((resolve, reject) => {
if (animationAborted) {
reject(new Error('Animation aborted'))
return
}
const timeoutId = setTimeout(() => {
if (animationAborted) {
reject(new Error('Animation aborted'))
} else {
resolve()
}
}, ms)
if (animationAborted) {
clearTimeout(timeoutId)
reject(new Error('Animation aborted'))
}
})
const nextColor = () => {
colorIndex.value = (colorIndex.value + 1) % activeSchemes.value.length
emit('colorChange', currentColors.value)
}
// Animation instance counter to prevent multiple concurrent animations
let animationInstanceId = 0
// Main animation sequence
const startAnimation = async () => {
if (isAnimating.value) return
const currentInstanceId = ++animationInstanceId
isAnimating.value = true
animationAborted = false
try {
// Reset states
lineDrawn.value = new Array(LINE_COUNT).fill(false)
isFilled.value = false
currentPhase.value = 'idle'
await nextTick()
calculatePathLengths()
await nextTick()
// Phase 1: Draw outlines (line by line)
currentPhase.value = 'drawOutline'
emit('phaseChange', 'drawOutline')
for (let i = 0; i < LINE_COUNT; i++) {
lineDrawn.value[i] = true
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
}
await wait(props.strokeDuration)
// Phase 2: Fill fade in
currentPhase.value = 'fillFadeIn'
emit('phaseChange', 'fillFadeIn')
isFilled.value = true
await wait(props.fillDuration)
// Hold
currentPhase.value = 'hold'
await wait(props.loopPause / 2)
// Phase 3: Fill fade out
currentPhase.value = 'fillFadeOut'
emit('phaseChange', 'fillFadeOut')
isFilled.value = false
await wait(props.fillDuration)
// Phase 4: Erase outlines (line by line)
currentPhase.value = 'eraseOutline'
emit('phaseChange', 'eraseOutline')
for (let i = 0; i < LINE_COUNT; i++) {
lineDrawn.value[i] = false
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
}
await wait(props.strokeDuration)
currentPhase.value = 'idle'
isAnimating.value = false
emit('animationComplete')
// Check if this animation instance is still valid before looping
if (props.loop && !animationAborted && currentInstanceId === animationInstanceId) {
if (props.cycleColors) nextColor()
await wait(props.loopPause / 2)
// Double check before recursing
if (!animationAborted && currentInstanceId === animationInstanceId) {
startAnimation()
}
}
} catch {
isAnimating.value = false
currentPhase.value = 'idle'
}
}
const reset = () => {
animationAborted = true
lineDrawn.value = new Array(LINE_COUNT).fill(false)
isFilled.value = false
currentPhase.value = 'idle'
isAnimating.value = false
}
const stop = () => {
animationAborted = true
}
watch(() => props.isDark, () => {
colorIndex.value = 0
})
defineExpose({ startAnimation, reset, stop, isAnimating, currentPhase, nextColor, colorIndex })
onMounted(async () => {
await nextTick()
calculatePathLengths()
if (props.autoStart && !hasStartedOnce) {
hasStartedOnce = true
startTimeoutId = setTimeout(startAnimation, 300)
}
})
onUnmounted(() => {
animationAborted = true
if (startTimeoutId) {
clearTimeout(startTimeoutId)
startTimeoutId = null
}
})
watch(() => props.autoStart, (newVal) => {
if (newVal && !isAnimating.value && !hasStartedOnce) {
hasStartedOnce = true
startAnimation()
}
})
</script>
<style scoped>
.line-by-line-logo {
display: flex;
align-items: center;
justify-content: center;
}
.logo-svg {
width: 100%;
height: 100%;
overflow: visible;
transform: translateZ(0);
backface-visibility: hidden;
}
.line-path {
will-change: stroke-dashoffset;
}
.fill-path {
will-change: opacity;
pointer-events: none;
}
.ghost-path {
opacity: 0.06;
}
</style>

View File

@@ -0,0 +1,400 @@
<template>
<div :class="wrapperClass">
<pre><code :class="`language-${language}`" v-html="highlightedCode"></code></pre>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import hljs from 'highlight.js/lib/core'
import bash from 'highlight.js/lib/languages/bash'
import json from 'highlight.js/lib/languages/json'
import ini from 'highlight.js/lib/languages/ini'
import javascript from 'highlight.js/lib/languages/javascript'
// 注册需要的语言
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('sh', bash)
hljs.registerLanguage('json', json)
hljs.registerLanguage('toml', ini)
hljs.registerLanguage('ini', ini)
hljs.registerLanguage('javascript', javascript)
const props = defineProps<{
code: string
language: string
dense?: boolean
}>()
const wrapperClass = computed(() =>
['code-highlight', props.dense ? 'code-highlight--dense' : '']
.filter(Boolean)
.join(' ')
)
// 自定义 bash 高亮增强
function enhanceBashHighlight(html: string, code: string): string {
// 如果 highlight.js 已经识别了 token直接返回
if (html.includes('hljs-')) {
return html
}
// 手动添加高亮
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
// 使用占位符保护URL
const urlPlaceholders: string[] = []
let result = escaped.replace(/(https?:\/\/[^\s]+)/g, (match) => {
const index = urlPlaceholders.length
urlPlaceholders.push(match)
return `__URL_PLACEHOLDER_${index}__`
})
// 高亮命令关键字
result = result
.replace(/\b(curl|npm|npx|git|bash|sh|powershell|iex|irm|wget|apt|yum|brew|pip|python|node|docker|kubectl)\b/g, '<span class="hljs-built_in">$1</span>')
.replace(/(^|\s)(-[a-zA-Z]+)/gm, '$1<span class="hljs-meta">$2</span>')
.replace(/(\|)/g, '<span class="hljs-keyword">$1</span>')
// 恢复URL并添加高亮
result = result.replace(/__URL_PLACEHOLDER_(\d+)__/g, (_, index) => {
return `<span class="hljs-string">${urlPlaceholders[parseInt(index)]}</span>`
})
return result
}
// 自定义 dotenv 高亮
function highlightDotenv(code: string): string {
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
return escaped
// 注释行
.replace(/^(#.*)$/gm, '<span class="hljs-comment">$1</span>')
// 环境变量 KEY=VALUE
.replace(/^([A-Z_][A-Z0-9_]*)(=)(.*)$/gm, (_, key, eq, value) => {
return `<span class="hljs-attr">${key}</span>${eq}<span class="hljs-string">${value}</span>`
})
}
const highlightedCode = computed(() => {
const lang = props.language.trim().toLowerCase()
const code = props.code ?? ''
let result: string
try {
if (lang === 'bash' || lang === 'sh') {
const highlighted = hljs.highlight(code, { language: 'bash' }).value
result = enhanceBashHighlight(highlighted, code)
} else if (lang === 'dotenv' || lang === 'env') {
result = highlightDotenv(code)
} else {
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
result = hljs.highlight(code, { language }).value
}
} catch (e) {
console.error('Highlight error:', e)
result = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
// Highlight placeholders that need user modification (e.g., your-api-key, latest-model-name)
result = highlightPlaceholders(result)
return result
})
// Highlight placeholder values that users need to modify
function highlightPlaceholders(html: string): string {
// Match common placeholder patterns (already HTML-escaped)
const placeholderPatterns = [
/your-api-key/gi,
/latest-model-name/gi,
/your-[a-z-]+/gi,
]
for (const pattern of placeholderPatterns) {
html = html.replace(pattern, (match) => {
// Avoid double-wrapping if already in a span
return `<span class="hljs-placeholder">${match}</span>`
})
}
return html
}
</script>
<style scoped>
.code-highlight {
width: 100%;
}
.code-highlight pre {
margin: 0;
padding: 0.9rem 1.1rem;
border-radius: 0.85rem;
border: 1px solid var(--color-border);
background-color: var(--color-code-background);
font-family: var(--font-mono, 'Cascadia Code', monospace);
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-code-text);
overflow-x: auto;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
letter-spacing: 0.01em;
}
.code-highlight code {
font-family: inherit;
font-size: inherit;
font-weight: 400;
}
.code-highlight--dense pre {
padding: 0.6rem 0.9rem;
font-size: 0.9375rem;
border-radius: 0.75rem;
line-height: 1.5;
/* Dense mode: transparent background for embedding in panels */
background-color: transparent;
border: 1px solid var(--color-border);
}
/* Highlight.js 浅色主题 */
.code-highlight :deep(.hljs-string) {
color: #24292e;
font-weight: 450;
}
.code-highlight :deep(.hljs-attr),
.code-highlight :deep(.hljs-attribute) {
color: #c96442;
font-weight: 500;
}
.code-highlight :deep(.hljs-keyword),
.code-highlight :deep(.hljs-selector-tag),
.code-highlight :deep(.hljs-built_in) {
color: #0066cc;
font-weight: 500;
}
.code-highlight :deep(.hljs-comment) {
color: #6a737d;
font-style: italic;
opacity: 0.8;
}
.code-highlight :deep(.hljs-function),
.code-highlight :deep(.hljs-title) {
color: #6f42c1;
font-weight: 500;
}
.code-highlight :deep(.hljs-number) {
color: #005cc5;
}
.code-highlight :deep(.hljs-literal),
.code-highlight :deep(.language-json .hljs-literal),
.code-highlight :deep(.language-json .hljs-literal .hljs-keyword) {
color: #24292e;
font-weight: normal;
}
.code-highlight :deep(.hljs-variable),
.code-highlight :deep(.hljs-property) {
color: #cc785c;
}
.code-highlight :deep(.hljs-punctuation) {
color: #24292e;
opacity: 0.7;
}
/* Placeholder values that need user modification */
.code-highlight :deep(.hljs-placeholder) {
color: #d73a49;
font-weight: 500;
font-style: italic;
}
/* Bash 命令样式 - 浅色模式 */
.code-highlight :deep(.hljs-built_in),
.code-highlight :deep(.hljs-name),
.code-highlight :deep(.language-bash .hljs-built_in),
.code-highlight :deep(.language-bash .hljs-name) {
color: #d73a49;
font-weight: 500;
}
.code-highlight :deep(.hljs-meta),
.code-highlight :deep(.language-bash .hljs-meta) {
color: #d73a49;
font-weight: 500;
}
.code-highlight :deep(.hljs-params),
.code-highlight :deep(.language-bash .hljs-params) {
color: #0366d6;
font-weight: 500;
}
.code-highlight :deep(.language-bash .hljs-keyword),
.code-highlight :deep(.language-bash .hljs-literal) {
color: #0366d6;
font-weight: 500;
}
.code-highlight :deep(.language-bash) {
color: #24292e;
}
.code-highlight :deep(.language-bash .hljs-string) {
color: #24292e;
font-weight: 400;
}
.code-highlight :deep(pre code.language-bash) {
color: #24292e;
}
.code-highlight :deep(pre code.language-bash .hljs-subst) {
color: #24292e;
}
/* Highlight.js 深色主题 */
.dark .code-highlight :deep(.hljs-string),
body[theme-mode='dark'] .code-highlight :deep(.hljs-string) {
color: #f1ead8;
font-weight: 450;
}
.dark .code-highlight :deep(.hljs-attr),
.dark .code-highlight :deep(.hljs-attribute),
body[theme-mode='dark'] .code-highlight :deep(.hljs-attr),
body[theme-mode='dark'] .code-highlight :deep(.hljs-attribute) {
color: #d4a27f;
font-weight: 500;
}
.dark .code-highlight :deep(.hljs-keyword),
.dark .code-highlight :deep(.hljs-selector-tag),
.dark .code-highlight :deep(.hljs-built_in),
body[theme-mode='dark'] .code-highlight :deep(.hljs-keyword),
body[theme-mode='dark'] .code-highlight :deep(.hljs-selector-tag),
body[theme-mode='dark'] .code-highlight :deep(.hljs-built_in) {
color: #569cd6;
font-weight: 500;
}
.dark .code-highlight :deep(.hljs-comment),
body[theme-mode='dark'] .code-highlight :deep(.hljs-comment) {
color: #6a9955;
font-style: italic;
opacity: 0.85;
}
.dark .code-highlight :deep(.hljs-function),
.dark .code-highlight :deep(.hljs-title),
body[theme-mode='dark'] .code-highlight :deep(.hljs-function),
body[theme-mode='dark'] .code-highlight :deep(.hljs-title) {
color: #dcdcaa;
font-weight: 500;
}
.dark .code-highlight :deep(.hljs-number),
body[theme-mode='dark'] .code-highlight :deep(.hljs-number) {
color: #b5cea8;
}
.dark .code-highlight :deep(.hljs-literal),
.dark .code-highlight :deep(.language-json .hljs-literal),
.dark .code-highlight :deep(.language-json .hljs-literal .hljs-keyword),
body[theme-mode='dark'] .code-highlight :deep(.hljs-literal),
body[theme-mode='dark'] .code-highlight :deep(.language-json .hljs-literal),
body[theme-mode='dark'] .code-highlight :deep(.language-json .hljs-literal .hljs-keyword) {
color: #e1e4e8;
font-weight: normal;
}
.dark .code-highlight :deep(.hljs-variable),
.dark .code-highlight :deep(.hljs-property),
body[theme-mode='dark'] .code-highlight :deep(.hljs-variable),
body[theme-mode='dark'] .code-highlight :deep(.hljs-property) {
color: #9cdcfe;
}
.dark .code-highlight :deep(.hljs-punctuation),
body[theme-mode='dark'] .code-highlight :deep(.hljs-punctuation) {
color: #d4d4d4;
opacity: 0.7;
}
/* Placeholder values - dark mode */
.dark .code-highlight :deep(.hljs-placeholder),
body[theme-mode='dark'] .code-highlight :deep(.hljs-placeholder) {
color: #f97583;
font-weight: 500;
font-style: italic;
}
/* Bash 深色主题 */
.dark .code-highlight :deep(.hljs-built_in),
.dark .code-highlight :deep(.hljs-name),
.dark .code-highlight :deep(.language-bash .hljs-built_in),
.dark .code-highlight :deep(.language-bash .hljs-name),
body[theme-mode='dark'] .code-highlight :deep(.hljs-built_in),
body[theme-mode='dark'] .code-highlight :deep(.hljs-name),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-built_in),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-name) {
color: #e67764;
font-weight: 500;
}
.dark .code-highlight :deep(.hljs-meta),
.dark .code-highlight :deep(.language-bash .hljs-meta),
body[theme-mode='dark'] .code-highlight :deep(.hljs-meta),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-meta) {
color: #e67764;
}
.dark .code-highlight :deep(.hljs-params),
.dark .code-highlight :deep(.language-bash .hljs-params),
body[theme-mode='dark'] .code-highlight :deep(.hljs-params),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-params) {
color: #6fa9e6;
font-weight: 500;
}
.dark .code-highlight :deep(.language-bash .hljs-keyword),
.dark .code-highlight :deep(.language-bash .hljs-literal),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-keyword),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-literal) {
color: #6fa9e6;
font-weight: 500;
}
.dark .code-highlight :deep(.language-bash),
body[theme-mode='dark'] .code-highlight :deep(.language-bash) {
color: #e1e4e8;
}
.dark .code-highlight :deep(.language-bash .hljs-string),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-string) {
color: #e1e4e8;
font-weight: 400;
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<AlertDialog
:model-value="state.isOpen"
:title="state.title || '确认操作'"
:description="state.message"
:confirm-text="state.confirmText || '确认'"
:cancel-text="state.cancelText || '取消'"
:type="state.variant || 'question'"
@update:model-value="handleClose"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</template>
<script setup lang="ts">
import AlertDialog from './common/AlertDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
const { state, handleConfirm, handleCancel } = useConfirm()
function handleClose(value: boolean) {
if (!value) {
handleCancel()
}
}
</script>

View File

@@ -0,0 +1,403 @@
<template>
<div
v-show="!isFullyHidden"
class="gemini-star-cluster absolute inset-0 overflow-hidden pointer-events-none"
:class="{ 'scattering': isScattering, 'fading-out': isFadingOut }"
>
<!-- SVG Defs for the Gemini multi-color gradient -->
<svg class="absolute w-0 h-0 overflow-hidden" aria-hidden="true">
<defs>
<!-- Main Gemini gradient (blue base with color overlays) -->
<linearGradient id="gemini-base" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1A73E8" />
<stop offset="50%" stop-color="#4285F4" />
<stop offset="100%" stop-color="#669DF6" />
</linearGradient>
<!-- Red accent overlay - from top -->
<linearGradient id="gemini-red-overlay" x1="50%" y1="0%" x2="50%" y2="50%">
<stop offset="0%" stop-color="#EA4335" />
<stop offset="100%" stop-color="#EA4335" stop-opacity="0" />
</linearGradient>
<!-- Yellow accent overlay - from left -->
<linearGradient id="gemini-yellow-overlay" x1="0%" y1="50%" x2="50%" y2="50%">
<stop offset="0%" stop-color="#FBBC04" />
<stop offset="100%" stop-color="#FBBC04" stop-opacity="0" />
</linearGradient>
<!-- Green accent overlay - from bottom -->
<linearGradient id="gemini-green-overlay" x1="50%" y1="100%" x2="50%" y2="50%">
<stop offset="0%" stop-color="#34A853" />
<stop offset="100%" stop-color="#34A853" stop-opacity="0" />
</linearGradient>
<!-- Glow filter -->
<filter id="star-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feFlood flood-color="#4285F4" flood-opacity="0.3" />
<feComposite in2="blur" operator="in" />
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
</svg>
<!-- Layer 1: Far background stars (smallest, lowest z-index) -->
<div class="stars-layer far-layer">
<div
v-for="star in farStars"
:key="`far-${star.id}`"
class="star-wrapper"
:class="{ 'star-visible': star.visible && hasScattered }"
:style="getStarStyle(star)"
>
<svg viewBox="0 0 24 24" class="star-svg">
<path :d="starPath" fill="url(#gemini-base)" />
<path :d="starPath" fill="url(#gemini-red-overlay)" />
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
<path :d="starPath" fill="url(#gemini-green-overlay)" />
</svg>
</div>
</div>
<!-- Layer 2: Mid-distance stars -->
<div class="stars-layer mid-layer">
<div
v-for="star in midStars"
:key="`mid-${star.id}`"
class="star-wrapper"
:class="{ 'star-visible': star.visible && hasScattered }"
:style="getStarStyle(star)"
>
<svg viewBox="0 0 24 24" class="star-svg" style="filter: url(#star-glow)">
<path :d="starPath" fill="url(#gemini-base)" />
<path :d="starPath" fill="url(#gemini-red-overlay)" />
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
<path :d="starPath" fill="url(#gemini-green-overlay)" />
</svg>
</div>
</div>
<!-- Layer 3: Near stars (largest, highest z-index - in front, not occluded by small stars) -->
<div class="stars-layer near-layer">
<div
v-for="star in nearStars"
:key="`near-${star.id}`"
class="star-wrapper"
:class="{ 'star-visible': star.visible && hasScattered }"
:style="getStarStyle(star)"
>
<svg viewBox="0 0 24 24" class="star-svg" style="filter: url(#star-glow)">
<path :d="starPath" fill="url(#gemini-base)" />
<path :d="starPath" fill="url(#gemini-red-overlay)" />
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
<path :d="starPath" fill="url(#gemini-green-overlay)" />
</svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
// Props for transition control
const props = withDefaults(defineProps<{
isVisible?: boolean
}>(), {
isVisible: true
})
// Gemini star SVG path (4-point star) - viewBox 0 0 24 24
const starPath = 'M12 1.5c.2 3.4 1.4 6.4 3.8 8.8 2.4 2.4 5.4 3.6 8.8 3.8-3.4.2-6.4 1.4-8.8 3.8-2.4 2.4-3.6 5.4-3.8 8.8-.2-3.4-1.4-6.4-3.8-8.8-2.4-2.4-5.4-3.6-8.8-3.8 3.4-.2 6.4-1.4 8.8-3.8 2.4-2.4 3.6-5.4 3.8-8.8z'
interface Star {
id: number
x: number
y: number
targetX: number
targetY: number
size: number
baseOpacity: number
visible: boolean
zIndex: number
// Animation props
twinkleDuration: number
twinkleDelay: number
}
const farStars = ref<Star[]>([])
const midStars = ref<Star[]>([])
const nearStars = ref<Star[]>([])
const hasScattered = ref(false)
const isScattering = ref(false)
const isFadingOut = ref(false)
const isFullyHidden = ref(false)
const CENTER_X = 50
const CENTER_Y = 50
// Generate constrained position to keep stars within bounds
const getConstrainedPosition = (size: number): { x: number; y: number } => {
// Calculate padding based on star size (in percentage)
// Assuming container is roughly 400-600px wide, use a safe estimate
const paddingPercent = Math.max(3, (size / 5))
const minPos = paddingPercent
const maxPos = 100 - paddingPercent
return {
x: minPos + Math.random() * (maxPos - minPos),
y: minPos + Math.random() * (maxPos - minPos)
}
}
const createStar = (id: number, sizeRange: [number, number, number, number, number, number], opacityBase: number, opacityRange: number, visibleThreshold: number, zIndexBase: number): Star => {
const sizeVariant = Math.random()
let size: number
if (sizeVariant < 0.4) {
size = sizeRange[0] + Math.random() * sizeRange[1]
} else if (sizeVariant < 0.7) {
size = sizeRange[2] + Math.random() * sizeRange[3]
} else {
size = sizeRange[4] + Math.random() * sizeRange[5]
}
const { x: targetX, y: targetY } = getConstrainedPosition(size)
return {
id,
x: CENTER_X,
y: CENTER_Y,
targetX,
targetY,
size,
baseOpacity: opacityBase + Math.random() * opacityRange,
visible: true,
zIndex: zIndexBase + Math.round(size),
twinkleDuration: 3 + Math.random() * 4, // 3-7s duration
twinkleDelay: Math.random() * 5 // 0-5s delay
}
}
const createStarLayers = () => {
const far: Star[] = []
for (let i = 0; i < 30; i++) {
far.push(createStar(i, [6, 4, 10, 6, 14, 6], 0.2, 0.25, 0.35, 0))
}
farStars.value = far
const mid: Star[] = []
for (let i = 0; i < 18; i++) {
mid.push(createStar(i, [18, 8, 24, 10, 32, 10], 0.35, 0.35, 0.45, 30))
}
midStars.value = mid
const near: Star[] = []
for (let i = 0; i < 10; i++) {
near.push(createStar(i, [40, 15, 55, 20, 70, 25], 0.5, 0.5, 0.55, 100))
}
nearStars.value = near
}
// Removed: handleAnimationIteration was causing stars to "teleport" visibly
// Stars now stay in place and only twinkle without changing position
// Scatter stars from center to their target positions
const scatterStars = () => {
isScattering.value = true
hasScattered.value = true
const allStars = [...farStars.value, ...midStars.value, ...nearStars.value]
allStars.forEach(star => {
star.x = star.targetX
star.y = star.targetY
})
setTimeout(() => {
isScattering.value = false
}, 2000)
}
// Reset stars to center
const resetStarsToCenter = () => {
hasScattered.value = false
isScattering.value = false
const allStars = [...farStars.value, ...midStars.value, ...nearStars.value]
allStars.forEach(star => {
star.x = CENTER_X
star.y = CENTER_Y
const { x, y } = getConstrainedPosition(star.size)
star.targetX = x
star.targetY = y
})
}
const getStarStyle = (star: Star): CSSProperties => {
return {
left: `${star.x}%`,
top: `${star.y}%`,
width: `${star.size}px`,
height: `${star.size}px`,
zIndex: star.zIndex,
'--base-opacity': star.baseOpacity,
'--twinkle-duration': `${star.twinkleDuration}s`,
'--twinkle-delay': `${star.twinkleDelay}s`
} as CSSProperties
}
// Watch for visibility changes
watch(() => props.isVisible, (newVal, oldVal) => {
if (newVal && !oldVal) {
// Entering: recreate stars if needed, reset to center then scatter
isFadingOut.value = false
isFullyHidden.value = false
// Recreate stars if they were cleared
if (farStars.value.length === 0) {
createStarLayers()
} else {
resetStarsToCenter()
}
setTimeout(() => {
scatterStars()
}, 50)
} else if (!newVal && oldVal) {
// Leaving: immediately stop animation and hide to release GPU resources
hasScattered.value = false
isScattering.value = false // Force stop scattering transition immediately
isFadingOut.value = true
// Wait for fade-out transition (150ms) to complete before cleanup
// Use a single timeout matching the CSS transition duration
setTimeout(() => {
if (!props.isVisible) {
isFullyHidden.value = true
isFadingOut.value = false
// Clear star arrays to fully release memory
farStars.value = []
midStars.value = []
nearStars.value = []
}
}, 180) // Slightly longer than CSS transition (150ms) to ensure smooth fade
}
}, { flush: 'post' }) // Change to post flush to ensure DOM is updated before our cleanup logic runs
onMounted(() => {
createStarLayers()
if (props.isVisible) {
isFullyHidden.value = false
setTimeout(() => {
scatterStars()
}, 100)
} else {
// Start hidden if not visible
isFullyHidden.value = true
}
})
onUnmounted(() => {
// No explicit cleanup needed for CSS animations
})
</script>
<style scoped>
.gemini-star-cluster {
perspective: 800px;
}
.stars-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.far-layer {
z-index: 1;
}
.mid-layer {
z-index: 2;
}
.near-layer {
z-index: 3;
}
.star-svg {
width: 100%;
height: 100%;
overflow: visible;
}
.star-wrapper {
position: absolute;
opacity: 0;
transform: scale(0.3) translate(-50%, -50%);
transition:
opacity 0.8s ease-out,
transform 0.8s ease-out;
/* Avoid persistent will-change to reduce GPU memory usage */
}
.star-wrapper.star-visible {
opacity: 0; /* Default to invisible, animation handles opacity */
transform: scale(1) translate(-50%, -50%);
animation: twinkle var(--twinkle-duration) ease-in-out infinite;
animation-delay: var(--twinkle-delay);
}
@keyframes twinkle {
0% {
opacity: 0;
transform: scale(0.5) translate(-50%, -50%);
}
50% {
opacity: var(--base-opacity);
transform: scale(1) translate(-50%, -50%);
filter: brightness(1.2);
}
100% {
opacity: 0;
transform: scale(0.5) translate(-50%, -50%);
}
}
/* Scatter animation - stars fly outward from center (2s duration) */
.scattering .star-wrapper {
transition:
opacity 0.8s ease-out,
transform 0.8s ease-out,
left 2s cubic-bezier(0.16, 1, 0.3, 1),
top 2s cubic-bezier(0.16, 1, 0.3, 1);
/* Only use will-change during active scatter animation */
will-change: opacity, transform, left, top;
}
/* Fade out animation - quick fade and disable child transitions */
.fading-out {
opacity: 0;
transition: opacity 0.15s ease-out;
pointer-events: none;
/* Force GPU layer removal */
will-change: auto;
}
.fading-out .star-wrapper {
transition: none !important;
will-change: auto !important;
animation: none !important;
opacity: 0 !important;
}
/* Depth blur effect */
.far-layer .star-wrapper {
filter: blur(0.5px);
}
.mid-layer .star-wrapper {
filter: blur(0.2px);
}
.near-layer .star-wrapper {
filter: none;
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,310 @@
<template>
<div
ref="rootEl"
class="platform-select"
:class="[`platform-select--${sizeClass}` , { 'platform-select--open': isOpen }]"
tabindex="0"
@click="handleRootClick"
@keydown.enter.prevent="toggleDropdown"
@keydown.space.prevent="toggleDropdown"
@keydown.escape.stop="closeDropdown"
>
<div class="platform-select__current">
<component :is="currentOption.icon" class="platform-select__icon" />
<div class="platform-select__text">
<p class="platform-select__label">{{ currentOption.label }}</p>
<p class="platform-select__hint">{{ currentOption.hint }}</p>
</div>
</div>
<ChevronDown class="platform-select__chevron" />
<transition name="platform-select-fade">
<ul v-if="isOpen" class="platform-select__dropdown">
<li
v-for="option in resolvedOptions"
:key="option.value"
class="platform-select__option"
:class="{ 'platform-select__option--active': option.value === modelValue }"
@click.stop="selectOption(option.value)"
>
<component :is="option.icon" class="platform-select__option-icon" />
<div class="platform-select__option-copy">
<p class="platform-select__option-label">{{ option.label }}</p>
<p class="platform-select__option-hint">{{ option.hint }}</p>
</div>
<Check class="platform-select__option-check" v-if="option.value === modelValue" />
</li>
</ul>
</transition>
</div>
</template>
<script lang="ts">
import { Apple, Box, Monitor, Terminal } from 'lucide-vue-next'
import type { Component } from 'vue'
export interface PlatformOption {
value: string
label: string
hint: string
icon: Component
command: string
}
// Default options for backward compatibility
export const defaultPlatformOptions: PlatformOption[] = [
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: '' },
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: '' }
]
// Preset configuration for each tool
export const platformPresets = {
default: {
options: defaultPlatformOptions,
defaultValue: 'mac'
},
claude: {
options: [
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: 'curl -fsSL https://claude.ai/install.sh | bash' },
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: 'irm https://claude.ai/install.ps1 | iex' },
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @anthropic-ai/claude-code' },
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask claude-code' }
] as PlatformOption[],
defaultValue: 'mac'
},
codex: {
options: [
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @openai/codex' },
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask codex' }
] as PlatformOption[],
defaultValue: 'nodejs'
},
gemini: {
options: [
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @google/gemini-cli' },
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install gemini-cli' }
] as PlatformOption[],
defaultValue: 'nodejs'
}
}
// Helper to get command by platform value
export function getCommand(preset: keyof typeof platformPresets, value: string): string {
const config = platformPresets[preset]
return config.options.find((opt: PlatformOption) => opt.value === value)?.command ?? ''
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Check, ChevronDown } from 'lucide-vue-next'
const props = defineProps<{
modelValue: string
size?: 'md' | 'lg'
options?: PlatformOption[]
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const rootEl = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const sizeClass = computed(() => props.size ?? 'md')
const resolvedOptions = computed(() => props.options ?? defaultPlatformOptions)
const currentOption = computed(() => resolvedOptions.value.find((option: PlatformOption) => option.value === props.modelValue) ?? resolvedOptions.value[0])
function toggleDropdown() {
isOpen.value = !isOpen.value
}
function closeDropdown() {
isOpen.value = false
}
function selectOption(value: string) {
if (value !== props.modelValue) {
emit('update:modelValue', value)
}
closeDropdown()
}
function handleRootClick(event: MouseEvent) {
const dropdown = rootEl.value?.querySelector('.platform-select__dropdown')
if (dropdown?.contains(event.target as Node)) {
return
}
toggleDropdown()
}
function handleClickOutside(event: MouseEvent) {
if (!rootEl.value) {
return
}
if (!rootEl.value.contains(event.target as Node)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.platform-select {
position: relative;
width: 11rem;
border: 1px solid var(--color-border);
border-radius: 0.9rem;
background-color: var(--color-background);
padding: 0.55rem 0.85rem;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.dark .platform-select {
background-color: var(--color-background);
}
.platform-select--lg {
width: 13rem;
}
.platform-select:focus-visible,
.platform-select--open {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.2);
}
.platform-select__current {
display: flex;
align-items: center;
gap: 0.65rem;
}
.platform-select__icon {
width: 1.1rem;
height: 1.1rem;
color: var(--color-primary);
}
.platform-select__text {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.platform-select__label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.platform-select__hint {
font-size: 0.7rem;
color: #91918d;
white-space: nowrap;
}
.dark .platform-select__hint {
color: #a8a29e;
}
.platform-select__chevron {
width: 0.9rem;
height: 0.9rem;
color: var(--color-border-soft);
}
.platform-select__dropdown {
position: absolute;
top: calc(100% + 0.45rem);
left: 0;
right: 0;
padding: 0.35rem;
border-radius: 1rem;
border: 1px solid var(--color-border);
background-color: var(--color-background);
box-shadow: 0 25px 55px rgba(0, 0, 0, 0.25);
z-index: 30;
backdrop-filter: blur(16px);
}
.platform-select__option {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.6rem;
border-radius: 0.75rem;
transition: background 0.2s ease, color 0.2s ease;
}
.platform-select__option:hover {
background: rgba(204, 120, 92, 0.1);
}
.platform-select__option--active {
background: rgba(204, 120, 92, 0.18);
}
.platform-select__option-icon {
width: 1rem;
height: 1rem;
color: var(--color-primary);
}
.platform-select__option-copy {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.platform-select__option-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.platform-select__option-hint {
font-size: 0.7rem;
color: #91918d;
white-space: nowrap;
}
.dark .platform-select__option-hint {
color: #a8a29e;
}
.platform-select__option-check {
margin-left: auto;
width: 0.85rem;
height: 0.85rem;
color: var(--color-primary);
}
.platform-select-fade-enter-active,
.platform-select-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.platform-select-fade-enter-from,
.platform-select-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,70 @@
<template>
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2">
<TransitionGroup
name="toast"
tag="div"
class="flex flex-col items-center gap-2"
>
<ToastWithProgress
v-for="toast in toasts"
:key="toast.id"
:toast="toast"
@remove="removeToast(toast.id)"
/>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import ToastWithProgress from './ToastWithProgress.vue'
import { useToast } from '@/composables/useToast'
const { toasts, removeToast } = useToast()
</script>
<style scoped>
/* 进入动画 - 从上方弹入 */
.toast-enter-active {
transition: all 0.4s cubic-bezier(0.2, 0.9, 0.3, 1);
}
.toast-enter-from {
transform: translateY(-20px) scale(0.95);
opacity: 0;
}
.toast-enter-to {
transform: translateY(0) scale(1);
opacity: 1;
}
/* 弹出动画 - 向上消失 */
.toast-leave-active {
transition: all 0.2s ease-out;
}
.toast-leave-from {
transform: translateY(0) scale(1);
opacity: 1;
}
.toast-leave-to {
transform: translateY(-20px) scale(0.95);
opacity: 0;
}
/* 移动动画 */
.toast-move {
transition: all 0.4s cubic-bezier(0.2, 0.9, 0.3, 1);
}
/* 响应式调整 */
@media (max-width: 640px) {
div.fixed {
top: 1rem;
left: 1rem;
right: 1rem;
transform: none;
}
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<div
role="alert"
class="flex items-start gap-4 px-6 py-3 rounded-lg border shadow-sm max-w-md"
:class="variantClasses"
>
<!-- 图标带圆形进度环 -->
<div class="relative shrink-0 w-8 h-8">
<!-- 进度环背景 -->
<svg
v-if="toast.duration && toast.duration > 0"
class="absolute inset-0 w-8 h-8 -rotate-90"
>
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-15"
/>
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
stroke-width="2"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
class="transition-[stroke-dashoffset] duration-75"
:class="progressColorClass"
/>
</svg>
<!-- 图标 -->
<div class="absolute inset-0 flex items-center justify-center" :class="iconClasses">
<component :is="icon" class="w-4 h-4" />
</div>
</div>
<!-- 内容 -->
<div class="flex-1 min-w-0">
<p v-if="toast.title" class="text-sm font-medium" :class="titleClasses">
{{ toast.title }}
</p>
<p v-if="toast.message" class="text-sm" :class="messageClasses">
{{ toast.message }}
</p>
</div>
<!-- 关闭按钮 -->
<button
@click="$emit('remove')"
class="shrink-0 p-1 rounded transition-colors opacity-40 hover:opacity-100"
:class="closeClasses"
type="button"
aria-label="关闭"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-vue-next'
interface Toast {
id: string
title?: string
message?: string
variant?: 'success' | 'error' | 'warning' | 'info'
duration?: number
}
const props = defineProps<{
toast: Toast
}>()
const emit = defineEmits<{
remove: []
}>()
const progress = ref(100)
let startTime = 0
let rafId: number | null = null
let timeoutId: ReturnType<typeof setTimeout> | null = null
// 圆形进度环参数
const circumference = 2 * Math.PI * 14 // r=14
const strokeDashoffset = computed(() => {
return circumference * (1 - progress.value / 100)
})
const updateProgress = () => {
if (!props.toast.duration || props.toast.duration <= 0) return
const elapsed = Date.now() - startTime
const remaining = Math.max(0, 100 - (elapsed / props.toast.duration) * 100)
progress.value = remaining
if (remaining <= 0) {
emit('remove')
} else {
rafId = requestAnimationFrame(updateProgress)
}
}
onMounted(() => {
if (props.toast.duration && props.toast.duration > 0) {
startTime = Date.now()
rafId = requestAnimationFrame(updateProgress)
// 保底 timeout确保即使在后台也能移除
timeoutId = setTimeout(() => {
emit('remove')
}, props.toast.duration + 100)
}
})
onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId)
if (timeoutId) clearTimeout(timeoutId)
})
const icons = {
success: CheckCircle2,
error: XCircle,
warning: AlertTriangle,
info: Info
}
const icon = computed(() => icons[props.toast.variant || 'info'])
const variantClasses = computed(() => {
const variant = props.toast.variant || 'info'
const classes: Record<string, string> = {
success: 'border-[#5F8D4E]/30 bg-white dark:bg-[var(--slate-dark)]',
error: 'border-[var(--error)]/30 bg-white dark:bg-[var(--slate-dark)]',
warning: 'border-[var(--book-cloth)]/30 bg-white dark:bg-[var(--slate-dark)]',
info: 'border-[var(--slate-medium)]/20 bg-white dark:bg-[var(--slate-dark)]'
}
return classes[variant]
})
const iconClasses = computed(() => {
const variant = props.toast.variant || 'info'
const classes: Record<string, string> = {
success: 'text-[#5F8D4E]',
error: 'text-[var(--error)]',
warning: 'text-[var(--book-cloth)]',
info: 'text-[var(--slate-medium)] dark:text-[var(--cloud-medium)]'
}
return classes[variant]
})
const progressColorClass = computed(() => {
const variant = props.toast.variant || 'info'
const classes: Record<string, string> = {
success: 'stroke-[#5F8D4E]',
error: 'stroke-[var(--error)]',
warning: 'stroke-[var(--book-cloth)]',
info: 'stroke-[var(--slate-medium)]'
}
return classes[variant]
})
const titleClasses = computed(() => {
return 'text-[var(--color-text)]'
})
const messageClasses = computed(() => {
return 'text-[var(--slate-medium)] dark:text-[var(--cloud-medium)]'
})
const closeClasses = computed(() => {
return 'text-[var(--slate-medium)] hover:bg-[var(--color-border-soft)] dark:text-[var(--cloud-medium)]'
})
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,148 @@
<template>
<div class="w-full h-full">
<canvas ref="chartRef"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
BarController,
Title,
Tooltip,
Legend,
type ChartData,
type ChartOptions
} from 'chart.js'
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
BarController,
Title,
Tooltip,
Legend
)
interface Props {
data: ChartData<'bar'>
options?: ChartOptions<'bar'>
height?: number
stacked?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 300,
stacked: true
})
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'bar'> | null = null
const defaultOptions: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
scales: {
x: {
stacked: true,
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(107, 114, 128)'
}
},
y: {
stacked: true,
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(107, 114, 128)'
}
}
},
plugins: {
legend: {
position: 'top',
labels: {
color: 'rgb(107, 114, 128)',
usePointStyle: true,
padding: 16
}
},
tooltip: {
backgroundColor: 'rgb(31, 41, 55)',
titleColor: 'rgb(243, 244, 246)',
bodyColor: 'rgb(243, 244, 246)',
borderColor: 'rgb(75, 85, 99)',
borderWidth: 1
}
}
}
function createChart() {
if (!chartRef.value) return
const stackedOptions = props.stacked ? {
scales: {
x: { ...defaultOptions.scales?.x, stacked: true },
y: { ...defaultOptions.scales?.y, stacked: true }
}
} : {
scales: {
x: { ...defaultOptions.scales?.x, stacked: false },
y: { ...defaultOptions.scales?.y, stacked: false }
}
}
chart = new ChartJS(chartRef.value, {
type: 'bar',
data: props.data,
options: {
...defaultOptions,
...stackedOptions,
...props.options
}
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.update('none')
}
}
onMounted(async () => {
await nextTick()
createChart()
})
onUnmounted(() => {
if (chart) {
chart.destroy()
chart = null
}
})
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
}
}, { deep: true })
</script>

View File

@@ -0,0 +1,128 @@
<template>
<div class="w-full h-full">
<canvas ref="chartRef"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend,
type ChartData,
type ChartOptions
} from 'chart.js'
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend
)
interface Props {
data: ChartData<'line'>
options?: ChartOptions<'line'>
height?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 300
})
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'line'> | null = null
const defaultOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: {
color: 'rgba(156, 163, 175, 0.1)' // gray-400 with opacity
},
ticks: {
color: 'rgb(107, 114, 128)' // gray-500
}
},
y: {
grid: {
color: 'rgba(156, 163, 175, 0.1)' // gray-400 with opacity
},
ticks: {
color: 'rgb(107, 114, 128)' // gray-500
}
}
},
plugins: {
legend: {
labels: {
color: 'rgb(107, 114, 128)' // gray-500
}
},
tooltip: {
backgroundColor: 'rgb(31, 41, 55)', // gray-800
titleColor: 'rgb(243, 244, 246)', // gray-100
bodyColor: 'rgb(243, 244, 246)', // gray-100
borderColor: 'rgb(75, 85, 99)', // gray-600
borderWidth: 1
}
}
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'line',
data: props.data,
options: {
...defaultOptions,
...props.options
}
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.update('none') // 禁用动画以提高性能
}
}
onMounted(async () => {
await nextTick()
createChart()
})
onUnmounted(() => {
if (chart) {
chart.destroy()
chart = null
}
})
// 监听数据变化
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
}
}, { deep: true })
</script>

View File

@@ -0,0 +1,165 @@
<template>
<Dialog :modelValue="modelValue" @update:modelValue="handleClose" :zIndex="80">
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<component :is="icon" class="h-5 w-5 flex-shrink-0" :class="iconColorClass" />
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ title }}</h3>
</div>
</div>
</div>
</template>
<template #default>
<!-- 描述 -->
<div class="space-y-3">
<p v-for="(line, index) in descriptionLines" :key="index" :class="getLineClass(index)">
{{ line }}
</p>
</div>
<!-- 自定义内容插槽 -->
<slot></slot>
</template>
<template #footer>
<!-- 取消按钮 -->
<Button
variant="outline"
@click="handleCancel"
:disabled="loading"
class="h-10 px-5"
>
{{ cancelText }}
</Button>
<!-- 确认按钮 -->
<Button
:variant="confirmVariant"
@click="handleConfirm"
:disabled="loading"
class="h-10 px-5"
>
<Loader2 v-if="loading" class="animate-spin h-4 w-4 mr-2" />
{{ confirmText }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import { AlertTriangle, AlertCircle, Info, Trash2, HelpCircle, Loader2 } from 'lucide-vue-next'
export type AlertType = 'danger' | 'destructive' | 'warning' | 'info' | 'question'
interface Props {
modelValue: boolean
title: string
description: string
type?: AlertType
confirmText?: string
cancelText?: string
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
type: 'warning',
confirmText: '确认',
cancelText: '取消',
loading: false
})
const emit = defineEmits<Emits>()
// 解析描述文本为多行
const descriptionLines = computed(() => {
return props.description.split('\n').filter(line => line.trim())
})
// 根据行索引获取样式(中间行高亮)
function getLineClass(index: number): string {
const total = descriptionLines.value.length
if (total <= 1) {
return 'text-sm text-muted-foreground'
}
// 中间行(不是第一行也不是最后一行)使用高亮样式
if (index > 0 && index < total - 1) {
return 'text-sm font-mono font-medium text-foreground bg-muted/50 px-3 py-2 rounded-md'
}
return 'text-sm text-muted-foreground'
}
// 根据类型获取图标
const icon = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return Trash2
case 'warning':
return AlertTriangle
case 'info':
return Info
case 'question':
return HelpCircle
default:
return AlertCircle
}
})
// 根据类型获取图标颜色样式
const iconColorClass = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return 'text-rose-600 dark:text-rose-400'
case 'warning':
return 'text-amber-600 dark:text-amber-400'
case 'info':
return 'text-primary'
case 'question':
return 'text-gray-600 dark:text-muted-foreground'
default:
return 'text-primary'
}
})
// 根据类型获取确认按钮样式
const confirmVariant = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return 'destructive' as const
case 'warning':
case 'info':
case 'question':
default:
return 'default' as const
}
})
function handleConfirm() {
emit('confirm')
}
function handleCancel() {
emit('cancel')
emit('update:modelValue', false)
}
function handleClose(value: boolean) {
if (!value && !props.loading) {
emit('update:modelValue', value)
emit('cancel')
}
}
</script>

View File

@@ -0,0 +1,285 @@
<template>
<div :class="containerClasses">
<!-- 图标 -->
<div :class="iconContainerClasses">
<component
v-if="icon"
:is="icon"
:class="iconClasses"
/>
<component
v-else
:is="defaultIcon"
:class="iconClasses"
/>
</div>
<!-- 标题 -->
<h3 v-if="title" :class="titleClasses">
{{ title }}
</h3>
<!-- 描述 -->
<p v-if="description" :class="descriptionClasses">
{{ description }}
</p>
<!-- 自定义内容插槽 -->
<div v-if="$slots.default" class="mt-4">
<slot />
</div>
<!-- 操作按钮 -->
<div v-if="$slots.actions || actionText" class="mt-6 flex flex-wrap items-center justify-center gap-3">
<slot name="actions">
<Button
v-if="actionText"
@click="handleAction"
:variant="actionVariant"
:size="actionSize"
>
<component v-if="actionIcon" :is="actionIcon" class="mr-2 h-4 w-4" />
{{ actionText }}
</Button>
</slot>
</div>
<!-- 次要操作 -->
<div v-if="$slots.secondary" class="mt-3">
<slot name="secondary" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button.vue'
import {
FileQuestion,
Search,
Inbox,
AlertCircle,
PackageOpen,
FolderOpen,
Database,
Filter
} from 'lucide-vue-next'
import type { Component } from 'vue'
type EmptyStateType = 'default' | 'search' | 'filter' | 'error' | 'empty' | 'notFound'
type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'link' | 'destructive'
type ButtonSize = 'sm' | 'default' | 'lg' | 'icon'
interface Props {
/** 空状态类型 */
type?: EmptyStateType
/** 自定义图标组件 */
icon?: Component
/** 标题 */
title?: string
/** 描述文本 */
description?: string
/** 操作按钮文本 */
actionText?: string
/** 操作按钮图标 */
actionIcon?: Component
/** 操作按钮变体 */
actionVariant?: ButtonVariant
/** 操作按钮大小 */
actionSize?: ButtonSize
/** 大小 */
size?: 'sm' | 'md' | 'lg'
/** 对齐方式 */
align?: 'left' | 'center' | 'right'
}
interface Emits {
(e: 'action'): void
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
actionVariant: 'default',
actionSize: 'default',
size: 'md',
align: 'center'
})
const emit = defineEmits<Emits>()
// 根据类型获取默认配置
const typeConfig = computed(() => {
const configs = {
default: {
icon: Inbox,
title: '暂无数据',
description: '当前没有可显示的内容'
},
search: {
icon: Search,
title: '未找到结果',
description: '尝试使用不同的关键词搜索'
},
filter: {
icon: Filter,
title: '无匹配结果',
description: '没有符合当前筛选条件的数据'
},
error: {
icon: AlertCircle,
title: '加载失败',
description: '数据加载过程中出现错误'
},
empty: {
icon: PackageOpen,
title: '这里空空如也',
description: '还没有任何内容'
},
notFound: {
icon: FileQuestion,
title: '未找到',
description: '请求的资源不存在'
}
}
return configs[props.type]
})
// 默认图标
const defaultIcon = computed(() => typeConfig.value.icon)
// 容器样式
const containerClasses = computed(() => {
const classes = ['empty-state']
// 大小
if (props.size === 'sm') {
classes.push('empty-state-sm', 'py-6')
} else if (props.size === 'lg') {
classes.push('empty-state-lg', 'py-16')
} else {
classes.push('empty-state-md', 'py-12')
}
// 对齐
if (props.align === 'left') {
classes.push('text-left')
} else if (props.align === 'right') {
classes.push('text-right')
} else {
classes.push('text-center')
}
return classes.join(' ')
})
// 图标容器样式
const iconContainerClasses = computed(() => {
const classes = [
'empty-state-icon-container',
'rounded-full',
'inline-flex',
'items-center',
'justify-center',
'mb-4'
]
// 大小和颜色
if (props.type === 'error') {
classes.push('bg-red-100', 'dark:bg-red-900/30')
} else if (props.type === 'search' || props.type === 'filter') {
classes.push('bg-blue-100', 'dark:bg-blue-900/30')
} else {
classes.push('bg-muted')
}
// 尺寸
if (props.size === 'sm') {
classes.push('w-12', 'h-12')
} else if (props.size === 'lg') {
classes.push('w-20', 'h-20')
} else {
classes.push('w-16', 'h-16')
}
return classes.join(' ')
})
// 图标样式
const iconClasses = computed(() => {
const classes = []
// 颜色
if (props.type === 'error') {
classes.push('text-red-600', 'dark:text-red-400')
} else if (props.type === 'search' || props.type === 'filter') {
classes.push('text-blue-600', 'dark:text-blue-400')
} else {
classes.push('text-muted-foreground')
}
// 尺寸
if (props.size === 'sm') {
classes.push('w-6', 'h-6')
} else if (props.size === 'lg') {
classes.push('w-10', 'h-10')
} else {
classes.push('w-8', 'h-8')
}
return classes.join(' ')
})
// 标题样式
const titleClasses = computed(() => {
const classes = ['font-semibold', 'text-foreground', 'mb-2']
if (props.size === 'sm') {
classes.push('text-base')
} else if (props.size === 'lg') {
classes.push('text-2xl')
} else {
classes.push('text-lg')
}
return classes.join(' ')
})
// 描述样式
const descriptionClasses = computed(() => {
const classes = ['text-muted-foreground', 'max-w-md']
if (props.align === 'center') {
classes.push('mx-auto')
}
if (props.size === 'sm') {
classes.push('text-xs')
} else if (props.size === 'lg') {
classes.push('text-base')
} else {
classes.push('text-sm')
}
return classes.join(' ')
})
// 处理操作
function handleAction() {
emit('action')
}
</script>
<style scoped>
.empty-state {
@apply flex flex-col items-center justify-center;
}
.empty-state-icon-container {
@apply transition-transform duration-200;
}
.empty-state:hover .empty-state-icon-container {
@apply scale-105;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div :class="containerClasses">
<div class="flex flex-col items-center gap-4">
<Skeleton v-if="variant === 'skeleton'" :class="skeletonClasses" />
<div v-else-if="variant === 'spinner'" class="relative">
<div class="h-12 w-12 animate-spin rounded-full border-4 border-muted border-t-primary"></div>
</div>
<div v-else-if="variant === 'pulse'" class="flex gap-2">
<div
v-for="i in 3"
:key="i"
class="h-3 w-3 animate-pulse rounded-full bg-primary"
:style="{ animationDelay: `${i * 150}ms` }"
></div>
</div>
<div v-if="message" class="text-sm text-muted-foreground">
{{ message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Skeleton from '@/components/ui/skeleton.vue'
interface Props {
variant?: 'skeleton' | 'spinner' | 'pulse'
message?: string
size?: 'sm' | 'md' | 'lg'
fullHeight?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'spinner',
size: 'md',
fullHeight: false,
})
const containerClasses = computed(() => {
const classes = ['flex items-center justify-center']
if (props.fullHeight) {
classes.push('min-h-[400px]')
} else {
const sizeMap = {
sm: 'py-8',
md: 'py-12',
lg: 'py-16',
}
classes.push(sizeMap[props.size])
}
return classes.join(' ')
})
const skeletonClasses = computed(() => {
const sizeMap = {
sm: 'h-24 w-full',
md: 'h-48 w-full',
lg: 'h-64 w-full',
}
return sizeMap[props.size]
})
</script>

View File

@@ -0,0 +1,9 @@
/**
* Common Components
* 常用的自定义业务组件
*/
// 状态和反馈组件
export { default as EmptyState } from './EmptyState.vue'
export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue'

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
<template>
<div class="app-shell" :class="{ 'pt-24': showNotice }">
<div
v-if="showNotice"
class="fixed top-6 left-0 right-0 z-50 flex justify-center px-4"
>
<slot name="notice" />
</div>
<div class="app-shell__backdrop">
<div class="app-shell__gradient app-shell__gradient--primary"></div>
<div class="app-shell__gradient app-shell__gradient--accent"></div>
</div>
<div class="app-shell__body">
<aside
v-if="$slots.sidebar"
class="app-shell__sidebar"
:class="sidebarClass"
>
<slot name="sidebar" />
</aside>
<div class="app-shell__content" :class="contentClass">
<slot name="header" />
<main :class="mainClass">
<slot />
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
showNotice?: boolean
contentClass?: string
mainClass?: string
sidebarClass?: string
}>(), {
showNotice: false,
contentClass: '',
mainClass: '',
sidebarClass: '',
})
const showNotice = computed(() => props.showNotice)
// contentClass and mainClass are now just the props, base classes are in template
const contentClass = computed(() => props.contentClass)
const mainClass = computed(() => ['app-shell__main', props.mainClass].filter(Boolean).join(' '))
const sidebarClass = computed(() => props.sidebarClass)
</script>

View File

@@ -0,0 +1,103 @@
<template>
<Card :class="cardClasses">
<div v-if="title || description || $slots.header" :class="headerClasses">
<slot name="header">
<div class="flex items-center justify-between">
<div>
<h3 v-if="title" class="text-lg font-medium leading-6 text-foreground">
{{ title }}
</h3>
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
<div v-if="$slots.actions">
<slot name="actions" />
</div>
</div>
</slot>
</div>
<div :class="contentClasses">
<slot />
</div>
<div v-if="$slots.footer" :class="footerClasses">
<slot name="footer" />
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Card from '@/components/ui/card.vue'
interface Props {
title?: string
description?: string
variant?: 'default' | 'elevated' | 'glass'
padding?: 'none' | 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
padding: 'md',
})
const cardClasses = computed(() => {
const classes = []
if (props.variant === 'elevated') {
classes.push('shadow-md')
} else if (props.variant === 'glass') {
classes.push('surface-glass')
}
return classes.join(' ')
})
const headerClasses = computed(() => {
const paddingMap = {
none: '',
sm: 'px-3 py-3',
md: 'px-4 py-5 sm:p-6',
lg: 'px-6 py-6 sm:p-8',
}
const classes = [paddingMap[props.padding]]
if (props.padding !== 'none') {
classes.push('border-b border-border')
}
return classes.join(' ')
})
const contentClasses = computed(() => {
const paddingMap = {
none: '',
sm: 'px-3 py-3',
md: 'px-4 py-5 sm:p-6',
lg: 'px-6 py-6 sm:p-8',
}
return paddingMap[props.padding]
})
const footerClasses = computed(() => {
const paddingMap = {
none: '',
sm: 'px-3 py-3',
md: 'px-4 py-5 sm:p-6',
lg: 'px-6 py-6 sm:p-8',
}
const classes = [paddingMap[props.padding]]
if (props.padding !== 'none') {
classes.push('border-t border-border')
}
return classes.join(' ')
})
</script>

View File

@@ -0,0 +1,123 @@
<template>
<div class="lg:hidden">
<div class="sticky top-0 z-40 space-y-4 pb-4">
<!-- Logo头部 - 移动端优化 -->
<div class="flex items-center gap-3 rounded-2xl bg-card/90 px-4 py-3 shadow-lg shadow-primary/20 ring-1 ring-border backdrop-blur">
<div class="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-2xl bg-background shadow-md shadow-primary/20 flex-shrink-0">
<img src="/aether_adaptive.svg" alt="Logo" class="h-8 w-8 sm:h-10 sm:w-10" />
</div>
<!-- 文字部分 - 小屏隐藏 -->
<div class="hidden sm:block">
<p class="text-sm font-semibold text-foreground">
Aether
</p>
<p class="text-xs text-muted-foreground">
AI 控制中心
</p>
</div>
</div>
<button
type="button"
class="flex w-full items-center gap-3 rounded-2xl bg-card/80 px-4 py-3 shadow-lg shadow-primary/20 ring-1 ring-border backdrop-blur transition hover:ring-primary/30"
@click="toggleMenu"
>
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary flex-shrink-0">
<Menu class="h-5 w-5" />
</div>
<div class="flex flex-1 flex-col text-left min-w-0">
<span class="text-sm font-semibold text-foreground truncate">
快速导航
</span>
<span class="text-xs text-muted-foreground truncate">
{{ activeItem ? `当前:${activeItem.name}` : '选择功能页面' }}
</span>
</div>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform duration-200 flex-shrink-0"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2 scale-95"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 -translate-y-1 scale-95"
>
<div
v-if="isOpen"
class="space-y-3 rounded-3xl bg-card/95 p-4 shadow-2xl ring-1 ring-border backdrop-blur-xl"
>
<SidebarNav
:items="props.items"
:is-active="isLinkActive"
:active-path="props.activePath"
list-class="space-y-2"
@navigate="handleNavigate"
/>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, ref, watch } from 'vue'
import { ChevronDown, Menu } from 'lucide-vue-next'
import SidebarNav from '@/components/layout/SidebarNav.vue'
export interface NavigationItem {
name: string
href: string
icon: Component
description?: string
}
export interface NavigationGroup {
title?: string
items: NavigationItem[]
}
const props = defineProps<{
items: NavigationGroup[]
activePath?: string
isActive?: (href: string) => boolean
isDark?: boolean
}>()
const isOpen = ref(false)
const activeItem = computed(() => {
for (const group of props.items) {
const found = group.items.find(item => isLinkActive(item.href))
if (found) return found
}
return null
})
function isLinkActive(href: string) {
if (props.isActive) {
return props.isActive(href)
}
if (props.activePath) {
return props.activePath === href || props.activePath.startsWith(`${href}/`)
}
return false
}
function toggleMenu() {
isOpen.value = !isOpen.value
}
function handleNavigate() {
isOpen.value = false
}
watch(() => props.activePath, () => {
isOpen.value = false
})
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div :class="containerClasses">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
padding?: 'none' | 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
maxWidth: '2xl',
padding: 'md',
})
const containerClasses = computed(() => {
const classes = ['w-full mx-auto']
// Max width
const maxWidthMap = {
sm: 'max-w-screen-sm',
md: 'max-w-screen-md',
lg: 'max-w-screen-lg',
xl: 'max-w-screen-xl',
'2xl': 'max-w-screen-2xl',
full: 'max-w-full',
}
classes.push(maxWidthMap[props.maxWidth])
// Padding
const paddingMap = {
none: '',
sm: 'px-4 py-4',
md: 'px-4 py-6 sm:px-6 lg:px-8',
lg: 'px-6 py-8 sm:px-8 lg:px-12',
}
if (props.padding !== 'none') {
classes.push(paddingMap[props.padding])
}
return classes.join(' ')
})
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
<slot name="icon">
<div v-if="icon" class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<component :is="icon" class="h-5 w-5 text-primary" />
</div>
</slot>
<div>
<h1 class="text-2xl font-semibold text-foreground sm:text-3xl">
{{ title }}
</h1>
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
</div>
</div>
<div v-if="$slots.actions" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
interface Props {
title: string
description?: string
icon?: Component
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,54 @@
<template>
<section :class="sectionClasses">
<div v-if="title || description || $slots.header" class="mb-6">
<slot name="header">
<div class="flex items-center justify-between">
<div>
<h2 v-if="title" class="text-lg font-medium text-foreground">
{{ title }}
</h2>
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
<div v-if="$slots.actions">
<slot name="actions" />
</div>
</div>
</slot>
</div>
<slot />
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
title?: string
description?: string
spacing?: 'none' | 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
spacing: 'md',
})
const sectionClasses = computed(() => {
const classes = []
const spacingMap = {
none: '',
sm: 'mb-4',
md: 'mb-6',
lg: 'mb-8',
}
if (props.spacing !== 'none') {
classes.push(spacingMap[props.spacing])
}
return classes.join(' ')
})
</script>

View File

@@ -0,0 +1,88 @@
<template>
<nav class="sidebar-nav w-full px-3">
<div v-for="(group, index) in items" :key="index" class="space-y-1 mb-5">
<!-- Section Header -->
<div v-if="group.title" class="px-2.5 pb-1 flex items-center gap-2" :class="index > 0 ? 'pt-1' : ''">
<span class="text-[10px] font-medium text-muted-foreground/50 font-mono tabular-nums">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.1em]">{{ group.title }}</span>
</div>
<!-- Links -->
<div class="space-y-0.5">
<template v-for="item in group.items" :key="item.href">
<RouterLink
:to="item.href"
class="group relative flex items-center justify-between px-2.5 py-2 rounded-lg transition-all duration-200"
:class="[
isItemActive(item.href)
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]"
@click="handleNavigate(item.href)"
>
<div class="flex items-center gap-2.5">
<component
:is="item.icon"
class="h-4 w-4 transition-colors duration-200"
:class="isItemActive(item.href) ? 'text-primary' : 'text-muted-foreground/70 group-hover:text-foreground'"
:stroke-width="isItemActive(item.href) ? 2 : 1.75"
/>
<span class="text-[13px] tracking-tight">{{ item.name }}</span>
</div>
<!-- Active Indicator -->
<div
v-if="isItemActive(item.href)"
class="w-1 h-1 rounded-full bg-primary"
></div>
</RouterLink>
</template>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
export interface NavigationItem {
name: string
href: string
icon: Component
description?: string
}
export interface NavigationGroup {
title?: string
items: NavigationItem[]
}
const props = defineProps<{
items: NavigationGroup[]
activePath?: string
isActive?: (href: string) => boolean
}>()
const emit = defineEmits<{
(e: 'navigate', href: string): void
}>()
function isItemActive(href: string) {
if (props.isActive) {
return props.isActive(href)
}
if (props.activePath) {
return props.activePath === href || props.activePath.startsWith(`${href}/`)
}
return false
}
function handleNavigate(href: string) {
emit('navigate', href)
}
</script>
<style scoped>
/* Navigation styles handled by Tailwind */
</style>

View File

@@ -0,0 +1,15 @@
/**
* Layout Components Library
* 基于 shadcn/ui 的自定义布局组件库
*/
// 页面布局组件
export { default as PageHeader } from './PageHeader.vue'
export { default as PageContainer } from './PageContainer.vue'
export { default as Section } from './Section.vue'
export { default as CardSection } from './CardSection.vue'
// 应用外壳组件
export { default as AppShell } from './AppShell.vue'
export { default as MobileNav } from './MobileNav.vue'
export { default as SidebarNav } from './SidebarNav.vue'

View File

@@ -0,0 +1,360 @@
<template>
<div class="space-y-4 w-full overflow-visible">
<div v-if="showHeader" class="flex items-center justify-between gap-4">
<div class="flex-shrink-0">
<p class="text-sm font-semibold">{{ title }}</p>
<p v-if="subtitle" class="text-xs text-muted-foreground">{{ subtitle }}</p>
</div>
<div v-if="weekColumns.length > 0" class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0">
<span class="flex-shrink-0"></span>
<div
v-for="(level, index) in legendLevels"
:key="index"
class="w-3 h-3 rounded-[3px] flex-shrink-0"
:style="getLegendStyle(level)"
></div>
<span class="flex-shrink-0"></span>
</div>
</div>
<div v-if="weekColumns.length > 0" class="flex w-full gap-3 overflow-visible">
<div class="flex flex-col text-[10px] text-muted-foreground flex-shrink-0" :style="verticalGapStyle">
<!-- Placeholder to align with month markers -->
<div class="text-[10px] mb-3 invisible">M</div>
<span :style="dayLabelStyle" class="flex items-center invisible">周日</span>
<span :style="dayLabelStyle" class="flex items-center"></span>
<span :style="dayLabelStyle" class="flex items-center invisible">周二</span>
<span :style="dayLabelStyle" class="flex items-center"></span>
<span :style="dayLabelStyle" class="flex items-center invisible">周四</span>
<span :style="dayLabelStyle" class="flex items-center"></span>
<span :style="dayLabelStyle" class="flex items-center invisible">周六</span>
</div>
<div class="flex-1 min-w-[240px] overflow-visible">
<div ref="heatmapWrapper" class="relative block w-full">
<div
v-if="tooltip.visible && tooltip.day"
class="fixed z-10 rounded-lg border border-border/70 bg-background px-3 py-2 text-xs shadow-lg backdrop-blur pointer-events-none"
:style="tooltipStyle"
>
<p class="font-medium">{{ tooltip.day.date }}</p>
<p class="mt-0.5">{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}</p>
<p class="text-[11px] text-muted-foreground">成本 {{ formatCurrency(tooltip.day.total_cost) }}</p>
</div>
<div class="flex text-[10px] text-muted-foreground/80 mb-3" :style="horizontalGapStyle">
<div
v-for="(week, weekIndex) in weekColumns"
:key="`month-${weekIndex}`"
:style="monthCellStyle"
class="text-center"
>
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
</div>
</div>
<div class="flex" :style="horizontalGapStyle">
<div v-for="(week, weekIndex) in weekColumns" :key="weekIndex" class="flex flex-col" :style="verticalGapStyle">
<div v-for="(day, dayIndex) in week" :key="dayIndex" class="relative group">
<div
v-if="day"
class="rounded-[4px] transition-all duration-200 hover:shadow-lg cursor-pointer cell-emerge"
:style="[cellSquareStyle, getCellStyle(day.requests), getCellAnimationDelay(weekIndex, dayIndex)]"
:title="buildTooltip(day)"
@mouseenter="handleHover(day, $event)"
@mouseleave="clearHover"
></div>
<div v-else :style="cellSquareStyle" class="rounded-[4px] bg-transparent"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<p v-else class="text-xs text-muted-foreground">暂无活跃数据</p>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ActivityHeatmap, ActivityHeatmapDay } from '@/types/activity'
import { formatCurrency, formatTokens } from '@/utils/format'
const props = withDefaults(defineProps<{
data?: ActivityHeatmap | null
title?: string
subtitle?: string
showHeader?: boolean
}>(), {
showHeader: true
})
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
type DayWithMeta = ActivityHeatmapDay & { dateObj: Date }
const heatmapWrapper = ref<HTMLElement | null>(null)
const heatmapWidth = ref(0)
const cellSize = ref(10)
const cellGap = ref(4)
const tooltip = ref<{ day: ActivityHeatmapDay | null; x: number; y: number; visible: boolean; below: boolean }>({
day: null,
x: 0,
y: 0,
visible: false,
below: false,
})
const tooltipStyle = computed(() => ({
top: `${tooltip.value.y}px`,
left: `${tooltip.value.x}px`,
transform: tooltip.value.below ? 'translate(-50%, 0)' : 'translate(-50%, -100%)',
}))
const cellSquareStyle = computed(() => ({
width: `${cellSize.value}px`,
height: `${cellSize.value}px`,
}))
const dayLabelStyle = computed(() => ({
height: `${cellSize.value}px`,
lineHeight: `${cellSize.value}px`,
}))
const monthCellStyle = computed(() => ({
width: `${cellSize.value}px`,
}))
const horizontalGapStyle = computed(() => ({
gap: `${cellGap.value}px`,
}))
const verticalGapStyle = computed(() => ({
rowGap: `${cellGap.value}px`,
}))
const weekColumns = computed(() => {
if (!props.data || !props.data.days || props.data.days.length === 0) {
return []
}
const dayEntries: DayWithMeta[] = props.data.days.map(day => ({
...day,
dateObj: new Date(`${day.date}T00:00:00Z`)
}))
const firstDay = dayEntries[0]?.dateObj
const padding: (DayWithMeta | null)[] = []
if (firstDay) {
const weekday = firstDay.getUTCDay() // 周日=0, 周一=1, ..., 周六=6
for (let i = 0; i < weekday; i++) {
padding.push(null)
}
}
const paddedDays: (DayWithMeta | null)[] = [...padding, ...dayEntries]
const remainder = paddedDays.length % 7
if (remainder !== 0) {
for (let i = remainder; i < 7; i++) {
paddedDays.push(null)
}
}
const chunked: (DayWithMeta | null)[][] = []
for (let i = 0; i < paddedDays.length; i += 7) {
chunked.push(paddedDays.slice(i, i + 7))
}
// Trim trailing empty weeks (weeks with all null cells)
let lastIndex = chunked.length - 1
while (lastIndex >= 0) {
const week = chunked[lastIndex]
const hasAnyDay = week.some(day => day !== null)
if (hasAnyDay) {
break
}
lastIndex--
}
return chunked.slice(0, lastIndex + 1)
})
const monthMarkers = computed(() => {
const markers: Record<number, string> = {}
const columns = weekColumns.value
let lastMonth: number | null = null
columns.forEach((week, index) => {
const firstValid = week.find((day): day is DayWithMeta => day !== null)
if (!firstValid) {
return
}
const month = firstValid.dateObj.getUTCMonth()
if (month === lastMonth) {
return
}
markers[index] = String(month + 1)
lastMonth = month
})
return markers
})
let resizeObserver: ResizeObserver | null = null
let mediaQuery: MediaQueryList | null = null
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
const recalcCellSize = () => {
const columnCount = weekColumns.value.length
if (!columnCount || !heatmapWidth.value) {
return
}
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
const rawSize = availableSpace / columnCount
// 不设置上限,让格子填满可用空间
cellSize.value = rawSize > 0 ? rawSize : 8
}
watch(
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
() => {
recalcCellSize()
},
{ immediate: true }
)
watch(
() => heatmapWrapper.value,
el => {
resizeObserver?.disconnect()
if (el && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(entries => {
if (!entries.length) {
return
}
heatmapWidth.value = entries[0].contentRect.width
recalcCellSize()
})
resizeObserver.observe(el)
} else {
heatmapWidth.value = 0
}
},
{ immediate: true }
)
onMounted(() => {
if (typeof window === 'undefined') {
return
}
mediaQuery = window.matchMedia('(min-width: 640px)')
const updateGap = () => {
cellGap.value = mediaQuery && mediaQuery.matches ? 4 : 2
recalcCellSize()
}
mediaQueryHandler = () => updateGap()
updateGap()
mediaQuery?.addEventListener('change', mediaQueryHandler)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener('change', mediaQueryHandler)
}
})
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const tooltipWidth = 200
const tooltipHeight = 72
// Calculate horizontal position (centered on cell)
let left = cellRect.left + cellRect.width / 2
const minLeft = tooltipWidth / 2 + 8
const maxLeft = window.innerWidth - tooltipWidth / 2 - 8
left = Math.min(Math.max(left, minLeft), maxLeft)
// Calculate vertical position
let top = cellRect.top - 12
let below = false
// If tooltip would go above viewport, show it below the cell
if (top - tooltipHeight < 0) {
top = cellRect.bottom + 12
below = true
}
tooltip.value = {
day,
x: left,
y: top,
visible: true,
below,
}
}
function clearHover() {
tooltip.value.visible = false
}
function getLegendStyle(alpha: number) {
return {
backgroundColor: `rgba(var(--color-primary-rgb), ${alpha})`
}
}
function getCellStyle(requests: number) {
const max = props.data?.max_requests || 1
if (!requests || max === 0) {
return {
backgroundColor: `rgba(var(--color-primary-rgb), 0.08)`
}
}
const ratio = Math.min(1, requests / max)
const minAlpha = 0.2
const maxAlpha = 0.95
const alpha = minAlpha + (maxAlpha - minAlpha) * ratio
return {
backgroundColor: `rgba(var(--color-primary-rgb), ${alpha})`
}
}
function buildTooltip(day: ActivityHeatmapDay): string {
const dateLabel = day.date
const costLabel = formatCurrency(day.total_cost || 0)
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
if (day.actual_total_cost !== undefined) {
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
}
return parts.join(' · ')
}
// 生成完全随机的动画延迟,每个格子独立随机弹出
const cellDelayMap = new Map<string, number>()
function getCellAnimationDelay(weekIndex: number, dayIndex: number) {
const key = `${weekIndex}-${dayIndex}`
if (!cellDelayMap.has(key)) {
// 完全随机延迟,范围 0-800ms
cellDelayMap.set(key, Math.random() * 800)
}
return {
animationDelay: `${cellDelayMap.get(key)}ms`
}
}
</script>
<style scoped>
.cell-emerge {
opacity: 0;
animation: cellEmerge 0.35s ease-out forwards;
}
@keyframes cellEmerge {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { AvatarFallback as AvatarFallbackPrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const fallbackClass = computed(() =>
cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
props.class
)
)
</script>
<template>
<AvatarFallbackPrimitive :class="fallbackClass">
<slot />
</AvatarFallbackPrimitive>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { AvatarImage as AvatarImagePrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
src?: string
alt: string
}
const props = withDefaults(defineProps<Props>(), {
alt: ''
})
const imageClass = computed(() =>
cn('aspect-square h-full w-full', props.class)
)
</script>
<template>
<AvatarImagePrimitive :class="imageClass" :src="src || ''" :alt="alt" />
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { AvatarRoot } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const avatarClass = computed(() =>
cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', props.class)
)
</script>
<template>
<AvatarRoot :class="avatarClass">
<slot />
</AvatarRoot>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground border-border bg-card/50',
success:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
warning:
'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
dark:
'border-transparent bg-foreground text-background hover:bg-foreground/80',
},
},
defaultVariants: {
variant: 'default',
},
}
)
interface Props {
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
class?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
})
const badgeClass = computed(() =>
cn(badgeVariants({ variant: props.variant }), props.class)
)
</script>
<template>
<div :class="badgeClass">
<slot />
</div>
</template>

View File

@@ -0,0 +1,61 @@
<template>
<button
:type="props.type"
:class="buttonClass"
:disabled="disabled"
v-bind="$attrs"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
size?: 'default' | 'sm' | 'lg' | 'icon'
disabled?: boolean
class?: string
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
size: 'default',
disabled: false,
type: 'button'
})
const buttonClass = computed(() => {
const baseClass =
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
const variantClasses = {
default:
'bg-primary text-white shadow-[0_20px_35px_rgba(204,120,92,0.35)] hover:bg-primary/90 hover:shadow-[0_25px_45px_rgba(204,120,92,0.45)]',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85 shadow-sm',
outline:
'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 shadow-sm backdrop-blur transition-all',
secondary:
'bg-secondary text-secondary-foreground shadow-inner hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
}
const sizeClasses = {
default: 'h-11 px-5',
sm: 'h-9 rounded-lg px-3',
lg: 'h-12 rounded-xl px-8 text-base',
icon: 'h-11 w-11 rounded-2xl',
}
return cn(
baseClass,
variantClasses[props.variant],
sizeClasses[props.size],
props.class
)
})
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div :class="cardClass">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
variant?: 'default' | 'glass' | 'elevated' | 'interactive' | 'subtle' | 'inset'
class?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default'
})
// 标准卡片变体定义
const variants = {
// 默认卡片 - 纯色背景,标准边框,用于主要内容容器
default: 'rounded-2xl border border-border bg-card text-card-foreground shadow-sm',
// 玻璃态卡片 - 半透明背景+模糊效果,用于嵌套内容/次要层级
glass: 'rounded-2xl border border-border bg-card/50 text-card-foreground shadow-sm backdrop-blur-sm',
// 提升卡片 - 更强阴影效果,用于模态对话框/强调内容
elevated: 'rounded-2xl border border-border bg-card text-card-foreground shadow-lg',
// 交互卡片 - 带hover效果,用于可点击列表项
interactive: 'rounded-2xl border border-border bg-card text-card-foreground shadow-sm transition-all duration-200 hover:shadow-md hover:border-primary/30 hover:-translate-y-0.5',
// 轻量卡片 - 更淡的边框,用于辅助信息区域
subtle: 'rounded-2xl border border-border/50 bg-card text-card-foreground shadow-sm',
// 嵌入式卡片 - 极淡背景,用于卡片内的子卡片(保持层级关系)
inset: 'rounded-xl border border-border/40 bg-card/40 text-card-foreground'
}
const cardClass = computed(() =>
cn(variants[props.variant], props.class)
)
</script>

View File

@@ -0,0 +1,47 @@
<template>
<input
type="checkbox"
:class="checkboxClass"
:checked="isChecked"
v-bind="$attrs"
@change="handleChange"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: boolean
checked?: boolean
class?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:checked': [value: boolean]
}>()
const checkboxClass = computed(() =>
cn(
'h-4 w-4 rounded border-border/60 bg-card/80 text-primary shadow-sm focus:ring-2 focus:ring-primary/40 focus:ring-offset-1 accent-primary',
props.class
)
)
const isChecked = computed<boolean>(() => {
if (typeof props.checked === 'boolean') {
return props.checked
}
return props.modelValue ?? false
})
function handleChange(event: Event) {
const target = event.target as HTMLInputElement
const value = target.checked
emit('update:modelValue', value)
emit('update:checked', value)
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 overflow-y-auto" :style="{ zIndex: containerZIndex }">
<!-- 背景遮罩 -->
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
:style="{ zIndex: backdropZIndex }"
@click="handleClose"
/>
</Transition>
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<!-- 对话框内容 -->
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
v-if="isOpen"
@click.stop
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border"
:style="{ zIndex: contentZIndex }"
:class="maxWidthClass"
>
<!-- Header 区域优先使用 slot否则使用 title prop -->
<slot name="header">
<div v-if="title" class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div v-if="icon" class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0" :class="iconClass">
<component :is="icon" class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ title }}</h3>
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
</div>
</div>
</div>
</slot>
<!-- 内容区域统一添加 padding -->
<div class="px-6 py-3">
<slot />
</div>
<!-- Footer 区域如果有 footer 插槽自动添加样式 -->
<div
v-if="slots.footer"
class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3"
>
<slot name="footer" />
</div>
</div>
</Transition>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, useSlots, type Component } from 'vue'
// Props 定义
const props = defineProps<{
open?: boolean
modelValue?: boolean
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
title?: string
description?: string
icon?: Component // Lucide icon component
iconClass?: string // Custom icon color class
zIndex?: number // Custom z-index for nested dialogs (default: 60)
}>()
// Emits 定义
const emit = defineEmits<{
'update:open': [value: boolean]
'update:modelValue': [value: boolean]
}>()
// 获取 slots 以便在模板中使用
const slots = useSlots()
// 统一处理 open 状态
const isOpen = computed(() => {
if (props.modelValue === true) {
return true
}
if (props.open === true) {
return true
}
return false
})
// 统一处理关闭事件
function handleClose() {
if (props.open !== undefined) {
emit('update:open', false)
}
if (props.modelValue !== undefined) {
emit('update:modelValue', false)
}
}
const maxWidthClass = computed(() => {
const sizeValue = props.maxWidth || props.size || 'md'
const sizes = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
'6xl': 'sm:max-w-6xl',
'7xl': 'sm:max-w-7xl'
}
return sizes[sizeValue]
})
// Z-index computed values for nested dialogs support
const containerZIndex = computed(() => props.zIndex || 60)
const backdropZIndex = computed(() => props.zIndex || 60)
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="px-6 py-5 bg-background">
<slot />
</div>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-muted-foreground">
<slot />
</p>
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3">
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="border-b border-border px-6 py-4">
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
<slot />
</h3>
</template>

View File

@@ -0,0 +1,67 @@
/**
* shadcn/ui Components
* 统一导出所有 shadcn UI 组件,简化导入
*
* 使用方式:
* import { Button, Input, Card } from '@/components/ui'
*/
// 布局组件
export { default as Card } from './card.vue'
export { default as Separator } from './separator.vue'
// Tabs 选项卡系列
export { default as Tabs } from './tabs.vue'
export { default as TabsContent } from './tabs-content.vue'
export { default as TabsList } from './tabs-list.vue'
export { default as TabsTrigger } from './tabs-trigger.vue'
// 表单组件
export { default as Button } from './button.vue'
export { default as Input } from './input.vue'
export { default as Textarea } from './textarea.vue'
export { default as Label } from './label.vue'
export { default as Checkbox } from './checkbox.vue'
export { default as Switch } from './switch.vue'
// Select 选择器系列
export { default as Select } from './select.vue'
export { default as SelectTrigger } from './select-trigger.vue'
export { default as SelectValue } from './select-value.vue'
export { default as SelectContent } from './select-content.vue'
export { default as SelectItem } from './select-item.vue'
// 反馈组件
export { default as Badge } from './badge.vue'
export { default as Skeleton } from './skeleton.vue'
// Dialog 对话框系列
export { default as Dialog } from './dialog/Dialog.vue'
export { default as DialogContent } from './dialog/DialogContent.vue'
export { default as DialogHeader } from './dialog/DialogHeader.vue'
export { default as DialogTitle } from './dialog/DialogTitle.vue'
export { default as DialogDescription } from './dialog/DialogDescription.vue'
export { default as DialogFooter } from './dialog/DialogFooter.vue'
// Table 表格系列
export { default as Table } from './table.vue'
export { default as TableBody } from './table-body.vue'
export { default as TableCell } from './table-cell.vue'
export { default as TableHead } from './table-head.vue'
export { default as TableHeader } from './table-header.vue'
export { default as TableRow } from './table-row.vue'
export { default as TableCard } from './table-card.vue'
// Avatar 头像系列
export { default as Avatar } from './avatar.vue'
export { default as AvatarFallback } from './avatar-fallback.vue'
export { default as AvatarImage } from './avatar-image.vue'
// 分页组件
export { default as Pagination } from './pagination.vue'
// 操作按钮
export { default as RefreshButton } from './refresh-button.vue'
// Tooltip 提示系列
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'

View File

@@ -0,0 +1,39 @@
<template>
<input
:class="inputClass"
:value="modelValue"
:autocomplete="autocompleteAttr"
v-bind="$attrs"
@input="handleInput"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: string | number
class?: string
autocomplete?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const autocompleteAttr = computed(() => props.autocomplete ?? 'off')
const inputClass = computed(() =>
cn(
'flex h-11 w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all',
props.class
)
)
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<label :class="labelClass">
<slot />
</label>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
const props = defineProps<Props>()
const labelClass = computed(() =>
cn(
'text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class
)
)
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="flex flex-col sm:flex-row gap-4 border-t border-border/60 px-6 py-4 bg-muted/20">
<!-- 左侧记录范围和每页数量 -->
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm text-muted-foreground">
<span class="font-medium">
显示 <span class="text-foreground font-semibold">{{ recordRange.start }}-{{ recordRange.end }}</span> <span class="text-foreground font-semibold">{{ total }}</span>
</span>
<Select
v-if="showPageSizeSelector"
v-model:open="pageSizeSelectOpen"
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger class="w-36 h-9 border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="size in pageSizeOptions"
:key="size"
:value="String(size)"
>
{{ size }} /
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 右侧分页按钮 -->
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(1)"
>
首页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(current - 1)"
>
上一页
</Button>
<!-- 页码按钮智能省略 -->
<template v-for="page in pageNumbers" :key="page">
<Button
v-if="typeof page === 'number'"
:variant="page === current ? 'default' : 'outline'"
size="sm"
class="h-9 min-w-[36px] px-2"
:class="page === current ? 'shadow-sm' : ''"
@click="handlePageChange(page)"
>
{{ page }}
</Button>
<span v-else class="px-2 text-muted-foreground select-none">{{ page }}</span>
</template>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(current + 1)"
>
下一页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(totalPages)"
>
末页
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
interface Props {
current: number
total: number
pageSize?: number
pageSizeOptions?: number[]
showPageSizeSelector?: boolean
}
interface Emits {
(e: 'update:current', value: number): void
(e: 'update:pageSize', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
pageSize: 20,
pageSizeOptions: () => [10, 20, 50, 100],
showPageSizeSelector: true
})
const emit = defineEmits<Emits>()
const pageSizeSelectOpen = ref(false)
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
const recordRange = computed(() => {
const start = (props.current - 1) * props.pageSize + 1
const end = Math.min(props.current * props.pageSize, props.total)
return { start, end }
})
const pageNumbers = computed(() => {
const pages: (number | string)[] = []
const total = totalPages.value
const current = props.current
if (total <= 7) {
// 总页数 <= 7全部显示
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// 总页数 > 7智能省略
if (current <= 3) {
// 当前页在前 3 页:[1, 2, 3, 4, 5, ..., total]
for (let i = 1; i <= 5; i++) pages.push(i)
pages.push('...')
pages.push(total)
} else if (current >= total - 2) {
// 当前页在后 3 页:[1, ..., total-4, total-3, total-2, total-1, total]
pages.push(1)
pages.push('...')
for (let i = total - 4; i <= total; i++) pages.push(i)
} else {
// 当前页在中间:[1, ..., current-1, current, current+1, ..., total]
pages.push(1)
pages.push('...')
for (let i = current - 1; i <= current + 1; i++) pages.push(i)
pages.push('...')
pages.push(total)
}
}
return pages
})
function handlePageChange(page: number) {
if (page < 1 || page > totalPages.value || page === props.current) {
return
}
emit('update:current', page)
}
function handlePageSizeChange(value: string) {
const newSize = parseInt(value)
if (newSize !== props.pageSize) {
emit('update:pageSize', newSize)
// 切换每页数量时,重置到第一页
emit('update:current', 1)
}
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="loading"
:title="title"
@click="handleClick"
>
<RefreshCcw class="w-3.5 h-3.5" :class="loading ? 'animate-spin' : ''" />
</Button>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui'
import { RefreshCcw } from 'lucide-vue-next'
interface Props {
loading?: boolean
title?: string
}
interface Emits {
(e: 'click'): void
}
withDefaults(defineProps<Props>(), {
loading: false,
title: '刷新'
})
const emit = defineEmits<Emits>()
function handleClick() {
emit('click')
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<SelectPortal>
<SelectContentPrimitive
v-bind="$attrs"
:class="contentClass"
:position="position"
:side="side"
:side-offset="sideOffset"
:align="align"
:align-offset="alignOffset"
>
<SelectViewport :class="viewportClass">
<slot />
</SelectViewport>
</SelectContentPrimitive>
</SelectPortal>
</template>
<script setup lang="ts">
import {
SelectContent as SelectContentPrimitive,
SelectPortal,
SelectViewport,
} from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
position?: 'item-aligned' | 'popper'
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
align?: 'start' | 'center' | 'end'
alignOffset?: number
}
const props = withDefaults(defineProps<Props>(), {
position: 'popper',
sideOffset: 4,
})
const contentClass = computed(() =>
cn(
'z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
)
const viewportClass = 'p-1 max-h-[var(--radix-select-content-available-height)]'
</script>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { SelectItem as SelectItemPrimitive, SelectItemIndicator, SelectItemText } from 'radix-vue'
import { Check } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
value: string
disabled?: boolean
}
const props = defineProps<Props>()
const itemClass = computed(() =>
cn(
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-primary/10 focus:bg-primary/15 text-foreground transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
)
</script>
<template>
<SelectItemPrimitive :class="itemClass" :value="value" :disabled="disabled">
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItemPrimitive>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { SelectTrigger as SelectTriggerPrimitive } from 'radix-vue'
import { ChevronDown } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
disabled?: boolean
}
const props = defineProps<Props>()
const triggerClass = computed(() =>
cn(
'flex h-11 w-full items-center justify-between rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 text-foreground cursor-pointer backdrop-blur transition-all',
props.class
)
)
</script>
<template>
<SelectTriggerPrimitive
v-bind="$attrs"
:class="triggerClass"
:disabled="disabled"
>
<slot />
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none" />
</SelectTriggerPrimitive>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { SelectValue as SelectValuePrimitive } from 'radix-vue'
interface Props {
placeholder?: string
}
const props = defineProps<Props>()
</script>
<template>
<SelectValuePrimitive :placeholder="placeholder">
<slot />
</SelectValuePrimitive>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { SelectRoot as SelectRootPrimitive } from 'radix-vue'
interface Props {
defaultValue?: string
modelValue?: string
open?: boolean
defaultOpen?: boolean
dir?: 'ltr' | 'rtl'
name?: string
autocomplete?: string
disabled?: boolean
required?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:open': [value: boolean]
}>()
const internalValue = ref<string | undefined>(
props.modelValue ?? props.defaultValue
)
const isModelControlled = computed(() => props.modelValue !== undefined)
watch(
() => props.modelValue,
value => {
if (isModelControlled.value) {
internalValue.value = value
}
}
)
const modelValueState = computed({
get: () => (isModelControlled.value ? props.modelValue : internalValue.value),
set: (value: string | undefined) => {
if (!isModelControlled.value) {
internalValue.value = value
}
// Cast to string for the emit signature when value exists
if (value !== undefined) {
emit('update:modelValue', value)
}
}
})
const internalOpen = ref<boolean>(
props.open ?? props.defaultOpen ?? false
)
const isOpenControlled = computed(() => props.open !== undefined)
watch(
() => props.open,
value => {
if (isOpenControlled.value && value !== undefined) {
internalOpen.value = value
}
}
)
const openState = computed({
get: () => (isOpenControlled.value ? props.open : internalOpen.value),
set: (value: boolean) => {
if (!isOpenControlled.value) {
internalOpen.value = value
}
emit('update:open', value)
}
})
</script>
<template>
<SelectRootPrimitive
:default-value="defaultValue"
:model-value="modelValueState"
:open="openState"
:default-open="defaultOpen"
:dir="dir"
:name="name"
:autocomplete="autocomplete"
:disabled="disabled"
:required="required"
@update:model-value="modelValueState = $event"
@update:open="openState = $event"
>
<slot />
</SelectRootPrimitive>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { Separator as SeparatorPrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
orientation?: 'horizontal' | 'vertical'
decorative?: boolean
}
const props = withDefaults(defineProps<Props>(), {
orientation: 'horizontal',
decorative: true,
})
const separatorClass = computed(() =>
cn(
'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
props.class
)
)
</script>
<template>
<SeparatorPrimitive
:class="separatorClass"
:orientation="orientation"
:decorative="decorative"
/>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const skeletonClass = computed(() =>
cn('animate-pulse rounded-md bg-muted', props.class)
)
</script>
<template>
<div :class="skeletonClass" />
</template>

View File

@@ -0,0 +1,29 @@
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
modelValue ? 'bg-primary' : 'bg-muted'
]"
@click="$emit('update:modelValue', !modelValue)"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
modelValue ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const bodyClass = computed(() =>
cn('[&_tr:last-child]:border-0', props.class)
)
</script>
<template>
<tbody :class="bodyClass">
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,34 @@
<template>
<Card class="overflow-hidden">
<!-- 标题和操作栏 -->
<div v-if="$slots.header || title" class="px-6 py-3.5 border-b border-border/60">
<slot name="header">
<div class="flex items-center justify-between gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold">{{ title }}</h3>
<!-- 右侧操作区 -->
<div v-if="$slots.actions" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</slot>
</div>
<!-- 表格内容 -->
<slot />
<!-- 分页 -->
<slot name="pagination" />
</Card>
</template>
<script setup lang="ts">
import { Card } from '@/components/ui'
interface Props {
title?: string
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const cellClass = computed(() =>
cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)
)
</script>
<template>
<td :class="cellClass">
<slot />
</td>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const headClass = computed(() =>
cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
props.class
)
)
</script>
<template>
<th :class="headClass">
<slot />
</th>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const headerClass = computed(() =>
cn('[&_tr]:border-b', props.class)
)
</script>
<template>
<thead :class="headerClass">
<slot />
</thead>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const rowClass = computed(() =>
cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
props.class
)
)
</script>
<template>
<tr :class="rowClass">
<slot />
</tr>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const tableClass = computed(() =>
cn('w-full caption-bottom text-sm', props.class)
)
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="tableClass">
<slot />
</table>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<template>
<div
v-show="isActive"
:class="contentClass"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
value: string
class?: string
}
const props = defineProps<Props>()
const activeTab = inject<Ref<string>>('activeTab')
const isActive = computed(() => activeTab?.value === props.value)
const contentClass = computed(() => {
return cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
props.class
)
})
</script>

View File

@@ -0,0 +1,205 @@
<template>
<div :class="listClass" ref="listRef">
<!-- 滑动指示器 - 放在按钮前面 -->
<div
class="tabs-indicator"
:style="indicatorStyle"
/>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
const props = defineProps<Props>()
const listRef = ref<HTMLElement | null>(null)
const indicatorStyle = ref<Record<string, string>>({
transform: 'translateX(0)',
width: '0px',
opacity: '0',
transition: 'none'
})
// 标记是否已完成首次定位(首次无动画)
const hasInitialized = ref(false)
// 记录当前激活的 tab 索引,用于计算相对位置
const activeIndex = ref(-1)
const activeTab = inject<Ref<string>>('activeTab')
// 检查是否有 grid 类(由外部传入)
const hasGridClass = computed(() => {
return props.class?.includes('grid')
})
const listClass = computed(() => {
return cn(
'tabs-list',
// 如果外部传入了 grid 类,就不使用默认的 inline-flex
!hasGridClass.value && 'inline-flex',
props.class
)
})
// 更新指示器位置
const updateIndicator = () => {
if (!listRef.value) return
const buttons = Array.from(
listRef.value.querySelectorAll<HTMLButtonElement>('button[data-value]')
)
// 确保所有 button 都已渲染且有 data-value
if (buttons.length === 0) return
const newIndex = buttons.findIndex(
(button) => button.dataset.value === activeTab?.value
)
if (newIndex === -1) {
indicatorStyle.value = {
transform: 'translateX(0)',
width: '0px',
opacity: '0',
transition: 'none'
}
return
}
const activeButton = buttons[newIndex]
const buttonRect = activeButton.getBoundingClientRect()
// 确保按钮已渲染
if (buttonRect.width === 0) return
// 计算相对位置:累加前面所有按钮的宽度
let offsetLeft = 0
for (let i = 0; i < newIndex; i++) {
offsetLeft += buttons[i].getBoundingClientRect().width
}
// 判断是否需要动画:
// 1. 首次初始化不需要动画
// 2. 索引变化(用户切换 tab需要动画
const isTabChange = hasInitialized.value && activeIndex.value !== newIndex && activeIndex.value !== -1
// 更新状态
activeIndex.value = newIndex
if (!hasInitialized.value) {
hasInitialized.value = true
}
indicatorStyle.value = {
transform: `translateX(${offsetLeft}px)`,
width: `${buttonRect.width}px`,
opacity: '1',
transition: isTabChange
? 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), width 0.2s cubic-bezier(0.4, 0, 0.2, 1)'
: 'none'
}
}
let rafId: number | null = null
const scheduleIndicatorUpdate = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
updateIndicator()
rafId = null
})
}
// 监听 activeTab 变化
watch(
() => activeTab?.value,
() => {
nextTick(() => {
scheduleIndicatorUpdate()
})
},
{ immediate: true, flush: 'post' }
)
// DOM 初始化/重新挂载时重新计算
watch(
() => listRef.value,
(el) => {
if (el) {
nextTick(() => {
scheduleIndicatorUpdate()
})
}
}
)
onMounted(() => {
// 重置状态
hasInitialized.value = false
activeIndex.value = -1
// 立即尝试更新
nextTick(() => {
scheduleIndicatorUpdate()
})
window.addEventListener('resize', scheduleIndicatorUpdate)
})
onUnmounted(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
window.removeEventListener('resize', scheduleIndicatorUpdate)
})
</script>
<style scoped>
.tabs-list {
position: relative;
height: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background-color: hsl(var(--muted) / 0.3);
padding: 0.25rem;
color: hsl(var(--muted-foreground));
border: 1px solid hsl(var(--border) / 0.6);
}
.tabs-indicator {
position: absolute;
z-index: 0;
top: 0.25rem;
bottom: 0.25rem;
left: 0;
border-radius: 0.375rem;
background: linear-gradient(
180deg,
hsl(var(--background)),
hsl(var(--background) / 0.95)
);
border: 1px solid hsl(var(--border));
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
.tabs-indicator {
background: linear-gradient(
180deg,
hsl(var(--accent)),
hsl(var(--accent) / 0.95)
);
border-color: hsl(var(--border) / 0.8);
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<button
:class="triggerClass"
:data-state="isActive ? 'active' : 'inactive'"
:data-value="props.value"
@click="handleClick"
type="button"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
value: string
class?: string
}
const props = defineProps<Props>()
const activeTab = inject<Ref<string>>('activeTab')
const setActiveTab = inject<(value: string) => void>('setActiveTab')
const isActive = computed(() => activeTab?.value === props.value)
const handleClick = () => {
setActiveTab?.(props.value)
}
const triggerClass = computed(() => {
return cn(
'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
isActive.value
? 'text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground',
props.class
)
})
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="tabs-root">
<slot />
</div>
</template>
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
interface Props {
defaultValue?: string
modelValue?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const activeTab = ref(props.modelValue || props.defaultValue || '')
watch(() => props.modelValue, (newValue) => {
if (newValue !== undefined) {
activeTab.value = newValue
}
})
const setActiveTab = (value: string) => {
activeTab.value = value
emit('update:modelValue', value)
}
provide('activeTab', activeTab)
provide('setActiveTab', setActiveTab)
</script>

View File

@@ -0,0 +1,35 @@
<template>
<textarea
:class="textareaClass"
:value="modelValue"
v-bind="$attrs"
@input="handleInput"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: string
class?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const textareaClass = computed(() =>
cn(
'flex min-h-[80px] w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all resize-none',
props.class
)
)
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { TooltipRoot } from 'radix-vue'
interface Props {
defaultOpen?: boolean
open?: boolean
delayDuration?: number
disableHoverableContent?: boolean
}
const props = withDefaults(defineProps<Props>(), {
defaultOpen: false,
delayDuration: 200,
disableHoverableContent: false
})
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
</script>
<template>
<TooltipRoot
:default-open="props.defaultOpen"
:open="props.open"
:delay-duration="props.delayDuration"
:disable-hoverable-content="props.disableHoverableContent"
@update:open="emit('update:open', $event)"
>
<slot />
</TooltipRoot>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { TooltipContent as TooltipContentPrimitive, TooltipPortal } from 'radix-vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
align?: 'start' | 'center' | 'end'
alignOffset?: number
avoidCollisions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
side: 'top',
sideOffset: 4,
align: 'center',
alignOffset: 0,
avoidCollisions: true
})
const contentClass = computed(() =>
cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
)
</script>
<template>
<TooltipPortal>
<TooltipContentPrimitive
:class="contentClass"
:side="side"
:side-offset="sideOffset"
:align="align"
:align-offset="alignOffset"
:avoid-collisions="avoidCollisions"
>
<slot />
</TooltipContentPrimitive>
</TooltipPortal>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { TooltipProvider as TooltipProviderPrimitive } from 'radix-vue'
interface Props {
delayDuration?: number
skipDelayDuration?: number
disableHoverableContent?: boolean
}
withDefaults(defineProps<Props>(), {
delayDuration: 200,
skipDelayDuration: 300,
disableHoverableContent: false
})
</script>
<template>
<TooltipProviderPrimitive
:delay-duration="delayDuration"
:skip-delay-duration="skipDelayDuration"
:disable-hoverable-content="disableHoverableContent"
>
<slot />
</TooltipProviderPrimitive>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { TooltipTrigger as TooltipTriggerPrimitive } from 'radix-vue'
interface Props {
asChild?: boolean
}
withDefaults(defineProps<Props>(), {
asChild: false
})
</script>
<template>
<TooltipTriggerPrimitive :as-child="asChild">
<slot />
</TooltipTriggerPrimitive>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'

View File

@@ -0,0 +1,182 @@
import { ref, type Ref } from 'vue'
import { useToast } from './useToast'
import { parseApiError } from '@/utils/errorParser'
/**
* 异步操作通用逻辑
*
* 统一处理:
* - Loading 状态管理
* - try/catch 错误处理
* - Toast 通知(成功/失败)
* - 可选的成功/失败回调
*
* @example
* ```typescript
* const { loading, execute } = useAsyncAction()
*
* // 简单用法
* await execute(() => api.deleteItem(id), {
* successMessage: '删除成功',
* })
*
* // 带回调
* await execute(() => api.createItem(data), {
* successMessage: '创建成功',
* onSuccess: (result) => {
* router.push(`/items/${result.id}`)
* },
* })
*
* // 自定义错误消息
* await execute(() => api.updateItem(id, data), {
* successMessage: '更新成功',
* errorMessage: '更新失败,请重试',
* })
* ```
*/
export interface UseAsyncActionOptions<T> {
/** 成功时显示的消息 */
successMessage?: string
/** 成功消息的标题 */
successTitle?: string
/** 失败时显示的消息(如果不提供,将解析 API 错误) */
errorMessage?: string
/** 失败消息的标题 */
errorTitle?: string
/** 成功时的回调 */
onSuccess?: (result: T) => void
/** 失败时的回调 */
onError?: (error: unknown) => void
/** 是否在失败时显示 toast默认 true */
showErrorToast?: boolean
/** 是否在成功时显示 toast默认有 successMessage 时为 true */
showSuccessToast?: boolean
}
export interface UseAsyncActionReturn {
/** 是否正在执行 */
loading: Ref<boolean>
/** 执行异步操作 */
execute: <T>(
action: () => Promise<T>,
options?: UseAsyncActionOptions<T>
) => Promise<T | undefined>
}
export function useAsyncAction(): UseAsyncActionReturn {
const loading = ref(false)
const { success, error: showError } = useToast()
async function execute<T>(
action: () => Promise<T>,
options?: UseAsyncActionOptions<T>
): Promise<T | undefined> {
const {
successMessage,
successTitle,
errorMessage,
errorTitle = '错误',
onSuccess,
onError,
showErrorToast = true,
showSuccessToast,
} = options || {}
loading.value = true
try {
const result = await action()
// 显示成功消息
const shouldShowSuccess = showSuccessToast ?? !!successMessage
if (shouldShowSuccess && successMessage) {
success(successMessage, successTitle)
}
// 调用成功回调
onSuccess?.(result)
return result
} catch (error) {
// 解析错误消息
const message = errorMessage || parseApiError(error, '操作失败')
// 显示错误消息
if (showErrorToast) {
showError(message, errorTitle)
}
// 调用错误回调
onError?.(error)
return undefined
} finally {
loading.value = false
}
}
return {
loading,
execute,
}
}
/**
* 创建多个独立的异步操作
*
* 当需要在同一个组件中跟踪多个独立的 loading 状态时使用
*
* @example
* ```typescript
* const { actions, isAnyLoading } = useMultipleAsyncActions(['save', 'delete', 'refresh'])
*
* // 使用各自的 loading 状态
* await actions.save.execute(() => api.save(data), { successMessage: '保存成功' })
* await actions.delete.execute(() => api.delete(id), { successMessage: '删除成功' })
*
* // 检查是否有任何操作正在进行
* <Button :disabled="isAnyLoading">操作</Button>
* ```
*/
export function useMultipleAsyncActions<K extends string>(
keys: K[]
): {
actions: Record<K, UseAsyncActionReturn>
isAnyLoading: Ref<boolean>
} {
const actions = {} as Record<K, UseAsyncActionReturn>
const loadingStates: Ref<boolean>[] = []
for (const key of keys) {
const action = useAsyncAction()
actions[key] = action
loadingStates.push(action.loading)
}
const isAnyLoading = ref(false)
// 使用 watchEffect 来监听所有 loading 状态
// 这里简化处理,在每次 execute 时会自动更新
// 如果需要响应式,可以使用 computed
const checkAnyLoading = () => {
isAnyLoading.value = loadingStates.some((state) => state.value)
}
// 包装每个 action 的 execute 以更新 isAnyLoading
for (const key of keys) {
const originalExecute = actions[key].execute
actions[key].execute = async (action, options) => {
checkAnyLoading()
try {
return await originalExecute(action, options)
} finally {
checkAnyLoading()
}
}
}
return {
actions,
isAnyLoading,
}
}

View File

@@ -0,0 +1,43 @@
import { useToast } from './useToast'
export function useClipboard() {
const { success, error: showError } = useToast()
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
success('已复制到剪贴板')
return true
}
// Fallback for non-secure contexts
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
success('已复制到剪贴板')
return true
}
showError('复制失败,请手动复制')
return false
} finally {
document.body.removeChild(textArea)
}
} catch (err) {
console.error('复制失败:', err)
showError('复制失败,请手动选择文本进行复制')
return false
}
}
return { copyToClipboard }
}

View File

@@ -0,0 +1,112 @@
import { ref } from 'vue'
export type ConfirmVariant = 'danger' | 'destructive' | 'warning' | 'info' | 'question'
export interface ConfirmOptions {
title?: string
message: string
confirmText?: string
cancelText?: string
variant?: ConfirmVariant
}
interface ConfirmState extends ConfirmOptions {
isOpen: boolean
resolve?: (value: boolean) => void
}
const state = ref<ConfirmState>({
isOpen: false,
message: '',
title: '确认操作',
confirmText: '确认',
cancelText: '取消',
variant: 'question'
})
export function useConfirm() {
/**
* 显示确认对话框
* @param options 对话框选项
* @returns Promise<boolean> - true表示确认false表示取消
*/
const confirm = (options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
state.value = {
isOpen: true,
title: options.title || '确认操作',
message: options.message,
confirmText: options.confirmText || '确认',
cancelText: options.cancelText || '取消',
variant: options.variant || 'question',
resolve
}
})
}
/**
* 便捷方法:危险操作确认(红色主题)
*/
const confirmDanger = (message: string, title?: string): Promise<boolean> => {
return confirm({
message,
title: title || '危险操作',
confirmText: '删除',
variant: 'danger'
})
}
/**
* 便捷方法:警告确认(黄色主题)
*/
const confirmWarning = (message: string, title?: string): Promise<boolean> => {
return confirm({
message,
title: title || '警告',
confirmText: '继续',
variant: 'warning'
})
}
/**
* 便捷方法:信息确认(蓝色主题)
*/
const confirmInfo = (message: string, title?: string): Promise<boolean> => {
return confirm({
message,
title: title || '提示',
confirmText: '确定',
variant: 'info'
})
}
/**
* 处理确认
*/
const handleConfirm = () => {
if (state.value.resolve) {
state.value.resolve(true)
}
state.value.isOpen = false
}
/**
* 处理取消
*/
const handleCancel = () => {
if (state.value.resolve) {
state.value.resolve(false)
}
state.value.isOpen = false
}
return {
state,
confirm,
confirmDanger,
confirmWarning,
confirmInfo,
handleConfirm,
handleCancel
}
}

View File

@@ -0,0 +1,132 @@
import { effectScope, ref, watch, computed } from 'vue'
const THEME_STORAGE_KEY = 'theme'
// 主题模式类型
export type ThemeMode = 'system' | 'light' | 'dark'
// 全局共享的状态
const themeMode = ref<ThemeMode>('system')
const isDark = ref(false)
let initialized = false
let scope: ReturnType<typeof effectScope> | null = null
let mediaQuery: MediaQueryList | null = null
const applyDarkMode = (value: boolean) => {
if (typeof document === 'undefined') {
return
}
document.documentElement.classList.toggle('dark', value)
if (document.body) {
document.body.setAttribute('theme-mode', value ? 'dark' : 'light')
}
}
const getSystemPreference = (): boolean => {
if (typeof window === 'undefined') {
return false
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
const updateDarkMode = () => {
if (themeMode.value === 'system') {
isDark.value = getSystemPreference()
} else {
isDark.value = themeMode.value === 'dark'
}
applyDarkMode(isDark.value)
}
const handleSystemChange = (e: MediaQueryListEvent) => {
if (themeMode.value === 'system') {
isDark.value = e.matches
applyDarkMode(isDark.value)
}
}
const ensureWatcher = () => {
if (scope) {
return
}
scope = effectScope(true)
scope.run(() => {
watch(
themeMode,
(value) => {
updateDarkMode()
if (typeof window !== 'undefined') {
localStorage.setItem(THEME_STORAGE_KEY, value)
}
},
{ flush: 'post' }
)
})
}
const initialize = () => {
if (initialized) {
return
}
initialized = true
ensureWatcher()
if (typeof window !== 'undefined') {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeMode | null
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'system') {
themeMode.value = storedTheme
} else {
// 兼容旧版本存储格式,旧版本直接存储 'dark' 或 'light'
themeMode.value = 'system'
}
// 监听系统主题变化
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', handleSystemChange)
}
updateDarkMode()
}
export function useDarkMode() {
initialize()
ensureWatcher()
applyDarkMode(isDark.value)
const setDarkMode = (value: boolean) => {
themeMode.value = value ? 'dark' : 'light'
}
const setThemeMode = (mode: ThemeMode) => {
themeMode.value = mode
}
const toggleDarkMode = () => {
// 循环切换system -> light -> dark -> system
if (themeMode.value === 'system') {
themeMode.value = 'light'
} else if (themeMode.value === 'light') {
themeMode.value = 'dark'
} else {
themeMode.value = 'system'
}
}
// 是否为跟随系统模式
const isSystemMode = computed(() => themeMode.value === 'system')
return {
isDark,
themeMode,
isSystemMode,
toggleDarkMode,
setDarkMode,
setThemeMode
}
}

View File

@@ -0,0 +1,115 @@
import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
/**
* 表单对话框通用逻辑
*
* 统一处理:
* - 编辑/创建模式切换
* - 对话框打开/关闭
* - 表单重置
* - 数据加载
* - Loading 状态阻止关闭
*
* @example
* ```typescript
* const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
* isOpen: () => props.modelValue,
* entity: () => props.provider,
* isLoading: loading,
* onClose: () => emit('update:modelValue', false),
* loadData: loadProviderData,
* resetForm,
* })
* ```
*/
export interface UseFormDialogOptions<E> {
/** 对话框是否打开getter 函数) */
isOpen: () => boolean
/** 编辑的实体getter 函数,返回 null/undefined 表示创建模式) */
entity: () => E | null | undefined
/** 是否处于加载状态 */
isLoading: Ref<boolean> | ComputedRef<boolean>
/** 关闭对话框的回调 */
onClose: () => void
/** 加载实体数据的函数(编辑模式时调用) */
loadData: () => void
/** 重置表单的函数 */
resetForm: () => void
/** 额外的加载状态(如清理缓存等),可选 */
extraLoadingStates?: Array<Ref<boolean> | ComputedRef<boolean>>
}
export interface UseFormDialogReturn {
/** 是否为编辑模式 */
isEditMode: ComputedRef<boolean>
/** 处理对话框更新事件(用于 @update:model-value */
handleDialogUpdate: (value: boolean) => void
/** 处理取消按钮点击 */
handleCancel: () => void
}
export function useFormDialog<E>(
options: UseFormDialogOptions<E>
): UseFormDialogReturn {
const {
isOpen,
entity,
isLoading,
onClose,
loadData,
resetForm,
extraLoadingStates = [],
} = options
// 是否为编辑模式
const isEditMode = computed(() => !!entity())
// 检查是否有任何加载状态
const isAnyLoading = computed(() => {
if (isLoading.value) return true
return extraLoadingStates.some((state) => state.value)
})
// 处理对话框更新事件
function handleDialogUpdate(value: boolean) {
// 加载中不允许关闭
if (!value && isAnyLoading.value) {
return
}
// 关闭时重置表单
if (!value) {
resetForm()
}
onClose()
}
// 处理取消按钮
function handleCancel() {
if (isAnyLoading.value) return
onClose()
}
// 监听打开状态变化
watch(isOpen, (val) => {
if (val) {
if (isEditMode.value && entity()) {
loadData()
} else {
resetForm()
}
}
})
// 监听实体变化(编辑模式切换)
watch(entity, (newEntity) => {
if (newEntity && isOpen()) {
loadData()
}
}, { immediate: true, deep: true })
return {
isEditMode,
handleDialogUpdate,
handleCancel,
}
}

View File

@@ -0,0 +1,64 @@
import { ref } from 'vue'
/**
* 处理表格行点击的 composable
* 用于在点击行时打开详情抽屉,同时排除文本选择操作
*/
export function useRowClick() {
// 记录 mousedown 时的位置和选中状态
const mouseDownPos = ref<{ x: number; y: number } | null>(null)
const hadSelectionOnMouseDown = ref(false)
/**
* 处理 mousedown 事件,记录位置和选中状态
*/
function handleMouseDown(event: MouseEvent) {
mouseDownPos.value = { x: event.clientX, y: event.clientY }
const selection = window.getSelection()
hadSelectionOnMouseDown.value = !!(selection && selection.toString().trim().length > 0)
}
/**
* 检查是否应该触发行点击事件
* 如果用户正在选择文本或取消选中,则返回 false
*/
function shouldTriggerRowClick(event?: MouseEvent): boolean {
// 如果 mousedown 时已有选中文本,说明用户可能是在取消选中
if (hadSelectionOnMouseDown.value) {
hadSelectionOnMouseDown.value = false
mouseDownPos.value = null
return false
}
// 如果鼠标移动超过阈值,说明用户在拖动选择
if (event && mouseDownPos.value) {
const dx = Math.abs(event.clientX - mouseDownPos.value.x)
const dy = Math.abs(event.clientY - mouseDownPos.value.y)
if (dx > 5 || dy > 5) {
mouseDownPos.value = null
return false
}
}
mouseDownPos.value = null
return true
}
/**
* 创建一个行点击处理函数
* @param callback 当应该触发点击时执行的回调
*/
function createRowClickHandler<T>(callback: (item: T) => void) {
return (event: MouseEvent, item: T) => {
if (shouldTriggerRowClick(event)) {
callback(item)
}
}
}
return {
handleMouseDown,
shouldTriggerRowClick,
createRowClickHandler
}
}

Some files were not shown because too many files have changed in this diff Show More