Merge branch 'fawney19:main' into main

This commit is contained in:
ZheFox
2026-05-20 22:49:49 +08:00
committed by GitHub
38 changed files with 1523 additions and 172 deletions

View File

@@ -1,5 +1,7 @@
import apiClient from './client'
const MODULE_MANAGEMENT_ORDER_CONFIG_KEY = 'module_management.extension_order'
export interface ModuleStatus {
name: string
available: boolean
@@ -75,6 +77,20 @@ const CHAT_PII_REDACTION_DEFAULT_CONFIG: ChatPiiRedactionConfig = {
placeholder_prefix: 'AETHER',
}
export function normalizeModuleManagementOrder(value: unknown): string[] {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const order: string[] = []
for (const item of value) {
if (typeof item !== 'string') continue
const name = item.trim()
if (!name || seen.has(name)) continue
seen.add(name)
order.push(name)
}
return order
}
function cloneDefaultChatPiiRedactionRules(): ChatPiiRedactionRule[] {
return CHAT_PII_REDACTION_DEFAULT_RULES.map(rule => ({ ...rule }))
}
@@ -189,6 +205,31 @@ export const modulesApi = {
return response.data
},
async getModuleManagementOrder(): Promise<string[]> {
try {
const response = await apiClient.get<{ key: string; value: unknown }>(
`/api/admin/system/configs/${MODULE_MANAGEMENT_ORDER_CONFIG_KEY}`
)
return normalizeModuleManagementOrder(response.data.value)
} catch (err) {
const status = (err as { response?: { status?: number } }).response?.status
if (status === 404) return []
throw err
}
},
async updateModuleManagementOrder(order: string[]): Promise<string[]> {
const normalized = normalizeModuleManagementOrder(order)
const response = await apiClient.put<{ key: string; value: unknown }>(
`/api/admin/system/configs/${MODULE_MANAGEMENT_ORDER_CONFIG_KEY}`,
{
value: normalized,
description: '模块管理扩展模块展示顺序',
},
)
return normalizeModuleManagementOrder(response.data.value)
},
async getChatPiiRedactionConfig(): Promise<ChatPiiRedactionConfig> {
const [enabled, rules, cacheTtlSeconds, placeholderPrefix] = await Promise.all([
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),

View File

@@ -71,7 +71,9 @@ export interface UsageByUser {
}
export interface UsageByProvider {
provider_id: string
provider_id?: string | null
provider_key?: string
provider_identity_source?: 'provider_id' | 'legacy_name'
provider: string
request_count: number
total_tokens: number

View File

@@ -46,7 +46,7 @@
</TableRow>
<TableRow
v-for="provider in data"
:key="provider.provider"
:key="provider.providerKey ?? provider.providerId ?? provider.provider"
>
<TableCell class="font-medium py-2 px-2">
{{ provider.provider }}

View File

@@ -129,6 +129,9 @@ describe('useUsageData', () => {
])
getUsageByProviderMock.mockResolvedValueOnce([
{
provider_id: 'provider-openai',
provider_key: 'provider-openai',
provider_identity_source: 'provider_id',
provider: 'OpenAI',
request_count: 3,
total_tokens: 300,
@@ -159,6 +162,11 @@ describe('useUsageData', () => {
})
expect(modelStats.value).toHaveLength(1)
expect(providerStats.value).toHaveLength(1)
expect(providerStats.value[0]).toMatchObject({
providerId: 'provider-openai',
providerKey: 'provider-openai',
providerIdentitySource: 'provider_id',
})
expect(apiFormatStats.value).toHaveLength(1)
expect(availableModels.value).toEqual(['gpt-5'])
expect(availableProviders.value).toEqual(['OpenAI'])

View File

@@ -173,6 +173,9 @@ export function useUsageData(options: UseUsageDataOptions) {
const visibleProviderData = providerData.filter(item => isUsageProviderVisible(item.provider))
providerStats.value = visibleProviderData.map(item => ({
providerId: item.provider_id,
providerKey: item.provider_key,
providerIdentitySource: item.provider_identity_source,
provider: item.provider,
requests: item.request_count,
totalTokens: item.total_tokens || 0,

View File

@@ -41,6 +41,9 @@ export interface EnhancedModelStatsItem extends ModelStatsItem {
// 提供商统计
export interface ProviderStatsItem {
providerId?: string | null
providerKey?: string
providerIdentitySource?: 'provider_id' | 'legacy_name'
provider: string
requests: number
totalTokens: number

View File

@@ -727,7 +727,7 @@ const navigation = computed(() => {
items: [
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
{ name: '套餐中心', href: '/dashboard/billing', icon: Package },
{ name: '我的邀请', href: '/dashboard/referral', icon: Gift },
...(moduleStore.isActive('referral') ? [{ name: '我的邀请', href: '/dashboard/referral', icon: Gift }] : []),
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
]
}
@@ -749,19 +749,21 @@ const navigation = computed(() => {
Puzzle,
Server,
SlidersHorizontal,
CreditCard,
Gift,
}
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)
const moduleMenuItems = Object.values(moduleStore.modules)
.filter(m => m.active && m.admin_route && m.admin_menu_group === 'system')
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
.map(m => ({
name: m.display_name,
href: m.admin_route ?? '',
icon: iconMap[m.admin_menu_icon || ''] || Puzzle
}))
const activeModuleItems = (group: string) =>
Object.values(moduleStore.modules)
.filter(m => m.active && m.admin_route && m.admin_menu_group === group)
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
.map(m => ({
name: m.display_name,
href: m.admin_route ?? '',
icon: iconMap[m.admin_menu_icon || ''] || Puzzle
}))
systemItems.push(...moduleMenuItems)
systemItems.push(...activeModuleItems('system'))
// 模块管理和系统设置放在最后
systemItems.push({ name: '模块管理', href: '/admin/modules', icon: Puzzle })
@@ -788,9 +790,8 @@ const navigation = computed(() => {
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
{ name: '支付配置', href: '/admin/payment-gateways', icon: CreditCard },
{ name: '套餐管理', href: '/admin/billing-plans', icon: Package },
{ name: '邀请返利', href: '/admin/referrals', icon: Gift },
...activeModuleItems('management'),
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]

View File

@@ -9,6 +9,7 @@ import type { User as AdminUser } from '@/api/users'
import type { AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
import type { ModuleStatus } from '@/api/modules'
// ========== 用户数据 ==========
@@ -902,7 +903,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
// ========== 系统配置 ==========
export const MOCK_SYSTEM_CONFIGS = [
export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; description?: string }> = [
{ key: 'rate_limit_enabled', value: true, description: '是否启用速率限制' },
{ key: 'default_rate_limit', value: 60, description: '默认速率限制(请求/分钟)' },
{ key: 'cache_enabled', value: true, description: '是否启用缓存' },
@@ -914,6 +915,161 @@ export const MOCK_SYSTEM_CONFIGS = [
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
]
const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & { health?: ModuleStatus['health'] }> = [
{
name: 'management_tokens',
display_name: '访问令牌',
description: '管理 API 访问令牌,支持细粒度权限控制和 IP 限制',
category: 'security',
available: true,
enabled: true,
config_validated: true,
config_error: null,
admin_route: '/admin/management-tokens',
admin_menu_icon: null,
admin_menu_group: null,
admin_menu_order: 0,
},
{
name: 'ldap',
display_name: 'LDAP 认证',
description: '支持通过 LDAP/Active Directory 进行用户认证',
category: 'auth',
available: true,
enabled: false,
config_validated: false,
config_error: '请先配置 LDAP 连接信息',
admin_route: '/admin/ldap',
admin_menu_icon: 'Users',
admin_menu_group: 'system',
admin_menu_order: 50,
},
{
name: 'oauth',
display_name: 'OAuth 登录',
description: '支持通过第三方 OAuth Provider 登录/绑定账号',
category: 'auth',
available: true,
enabled: true,
config_validated: true,
config_error: null,
admin_route: '/admin/oauth',
admin_menu_icon: 'Key',
admin_menu_group: null,
admin_menu_order: 55,
},
{
name: 'notification_email',
display_name: '异常通知',
description: '为 5xx 异常发送邮件通知,可在模块管理中启用或禁用',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先完成邮件配置SMTP',
admin_route: null,
admin_menu_icon: 'Mail',
admin_menu_group: 'system',
admin_menu_order: 58,
},
{
name: 'chat_pii_redaction',
display_name: '敏感信息保护',
description: '发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。',
category: 'security',
available: true,
enabled: false,
config_validated: true,
config_error: null,
admin_route: '/admin/modules/chat-pii-redaction',
admin_menu_icon: 'ShieldCheck',
admin_menu_group: 'system',
admin_menu_order: 59,
},
{
name: 'model_directives',
display_name: '模型后缀参数',
description: '允许通过模型名后缀覆盖推理参数',
category: 'integration',
available: true,
enabled: true,
config_validated: true,
config_error: null,
admin_route: '/admin/model-directives',
admin_menu_icon: 'SlidersHorizontal',
admin_menu_group: null,
admin_menu_order: 59,
},
{
name: 'gemini_files',
display_name: '文件缓存',
description: '管理 Gemini Files API 上传的文件,支持文件上传、查看和删除',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '至少启用一个具有「Gemini 文件 API」能力的 Key',
admin_route: '/admin/gemini-files',
admin_menu_icon: 'FileUp',
admin_menu_group: 'system',
admin_menu_order: 60,
health: 'degraded',
},
{
name: 'proxy_nodes',
display_name: '代理节点',
description: '添加Http/Socket代理节点, 或使用Aether-Proxy自动连接代理节点.',
category: 'integration',
available: true,
enabled: true,
config_validated: true,
config_error: null,
admin_route: '/admin/proxy-nodes',
admin_menu_icon: 'Server',
admin_menu_group: 'system',
admin_menu_order: 60,
},
{
name: 'payment_gateways',
display_name: '支付配置',
description: '配置易支付、支付宝官方、微信支付官方和 Stripe 等支付网关',
category: 'integration',
available: true,
enabled: false,
config_validated: true,
config_error: null,
admin_route: '/admin/payment-gateways',
admin_menu_icon: 'CreditCard',
admin_menu_group: null,
admin_menu_order: 70,
},
{
name: 'referral',
display_name: '邀请返利',
description: '管理用户邀请关系与返利记录,支持比例返利和人头返利',
category: 'integration',
available: true,
enabled: false,
config_validated: true,
config_error: null,
admin_route: '/admin/referrals',
admin_menu_icon: 'Gift',
admin_menu_group: 'management',
admin_menu_order: 75,
},
]
export const MOCK_MODULE_STATUSES: Record<string, ModuleStatus> = Object.fromEntries(
MOCK_MODULE_DEFINITIONS.map(module => [
module.name,
{
...module,
active: module.available && module.enabled && module.config_validated,
health: module.health ?? 'healthy',
},
])
) as Record<string, ModuleStatus>
// ========== API 格式 ==========
export const MOCK_API_FORMATS = {

View File

@@ -22,6 +22,7 @@ import {
MOCK_PROVIDERS,
MOCK_GLOBAL_MODELS,
MOCK_SYSTEM_CONFIGS,
MOCK_MODULE_STATUSES,
MOCK_API_FORMATS
} from './data'
@@ -1290,6 +1291,13 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse({ requests: [] })
},
// ========== Admin: Modules ==========
'GET /api/admin/modules/status': async () => {
await delay()
requireAdmin()
return createMockResponse(MOCK_MODULE_STATUSES)
},
// ========== Admin: System ==========
'GET /api/admin/system/configs': async () => {
await delay()
@@ -1788,6 +1796,71 @@ function generateMockModelsForProvider(providerId: string) {
// ========== 注册动态路由 ==========
// 系统配置详情
registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_config, params) => {
await delay()
requireAdmin()
const key = decodeURIComponent(params.configKey)
const entry = MOCK_SYSTEM_CONFIGS.find(item => item.key === key)
if (!entry) {
throw { response: createMockResponse({ detail: `配置项 '${key}' 不存在` }, 404) }
}
return createMockResponse({ key: entry.key, value: entry.value, description: entry.description })
})
// 系统配置更新
registerDynamicRoute('PUT', '/api/admin/system/configs/:configKey', async (config, params) => {
await delay()
requireAdmin()
const key = decodeURIComponent(params.configKey)
const body = JSON.parse(config.data || '{}') as { value?: unknown; description?: string }
const index = MOCK_SYSTEM_CONFIGS.findIndex(item => item.key === key)
const entry = {
key,
value: body.value ?? null,
description: body.description,
}
if (index === -1) {
MOCK_SYSTEM_CONFIGS.push(entry)
} else {
MOCK_SYSTEM_CONFIGS[index] = {
...MOCK_SYSTEM_CONFIGS[index],
...entry,
}
}
return createMockResponse(entry)
})
// 模块状态详情
registerDynamicRoute('GET', '/api/admin/modules/status/:moduleName', async (_config, params) => {
await delay()
requireAdmin()
const moduleStatus = MOCK_MODULE_STATUSES[params.moduleName]
if (!moduleStatus) {
throw { response: createMockResponse({ detail: '模块不存在' }, 404) }
}
return createMockResponse(moduleStatus)
})
// 模块启用状态更新
registerDynamicRoute('PUT', '/api/admin/modules/status/:moduleName/enabled', async (config, params) => {
await delay()
requireAdmin()
const moduleStatus = MOCK_MODULE_STATUSES[params.moduleName]
if (!moduleStatus) {
throw { response: createMockResponse({ detail: '模块不存在' }, 404) }
}
const body = JSON.parse(config.data || '{}') as { enabled?: boolean }
const enabled = body.enabled === true
const updated = {
...moduleStatus,
enabled,
active: moduleStatus.available && enabled && moduleStatus.config_validated,
}
MOCK_MODULE_STATUSES[params.moduleName] = updated
return createMockResponse(updated)
})
// Provider 详情
registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_config, params) => {
await delay()

View File

@@ -79,9 +79,25 @@
</div>
<!-- 扩展模块 -->
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
扩展模块
</h3>
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
扩展模块
</h3>
<Button
v-if="hasCustomModuleOrder"
variant="outline"
size="sm"
class="gap-1.5"
:disabled="loading || orderSaving"
@click="resetModuleOrder"
>
<RotateCcw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': orderSaving }"
/>
恢复默认
</Button>
</div>
<!-- 模块卡片网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
@@ -89,11 +105,23 @@
v-for="module in filteredModules"
:key="module.name"
class="group relative border rounded-2xl p-6 transition-all duration-200 hover:shadow-lg"
:class="{
'bg-muted/40 border-muted': !module.available,
'border-primary/40 bg-gradient-to-br from-primary/5 to-primary/10 shadow-sm': module.active,
'border-border bg-card hover:border-primary/20': !module.active && module.available
}"
:class="[
{
'bg-muted/40 border-muted': !module.available,
'border-primary/40 bg-gradient-to-br from-primary/5 to-primary/10 shadow-sm': module.active,
'border-border bg-card hover:border-primary/20': !module.active && module.available
},
draggedModuleName === module.name ? 'opacity-70 ring-2 ring-primary/30' : '',
dragOverModuleName === module.name ? 'ring-2 ring-primary/40 border-primary/50' : '',
canReorderModules ? 'cursor-grab active:cursor-grabbing' : ''
]"
:draggable="canReorderModules"
:title="orderSaving ? '正在保存排序' : '拖拽卡片调整顺序'"
@dragstart="handleModuleDragStart(module.name, $event)"
@dragend="handleModuleDragEnd"
@dragover.prevent="handleModuleDragOver(module.name)"
@dragleave="handleModuleDragLeave(module.name)"
@drop.prevent="handleModuleDrop(module.name)"
>
<!-- 状态指示器 -->
<div class="absolute top-5 right-5">
@@ -121,7 +149,7 @@
class="w-5 h-5"
/>
</div>
<div class="flex-1 min-w-0 pt-1">
<div class="flex-1 min-w-0 pt-1 pr-8">
<h4 class="font-semibold text-base truncate">
{{ module.display_name }}
</h4>
@@ -214,7 +242,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { RefreshCw, Puzzle, Users, Shield, Gauge, Link, Search, Settings } from 'lucide-vue-next'
import {
RefreshCw,
Puzzle,
Users,
Shield,
Gauge,
Link,
Search,
Settings,
RotateCcw,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Switch from '@/components/ui/switch.vue'
import Input from '@/components/ui/input.vue'
@@ -224,6 +262,7 @@ import { useModuleStore } from '@/stores/modules'
import { BUILTIN_TOOLS } from '@/config/builtin-tools'
import { log } from '@/utils/logger'
import { getErrorMessage } from '@/types/api-error'
import { modulesApi, type ModuleStatus } from '@/api/modules'
const router = useRouter()
const { success, error } = useToast()
@@ -232,6 +271,10 @@ const moduleStore = useModuleStore()
const loading = ref(false)
const toggling = ref<Record<string, boolean>>({})
const searchQuery = ref('')
const moduleOrder = ref<string[]>([])
const orderSaving = ref(false)
const draggedModuleName = ref<string | null>(null)
const dragOverModuleName = ref<string | null>(null)
// 过滤后的内置工具
const filteredBuiltinTools = computed(() => {
@@ -262,12 +305,63 @@ function getModuleStatusCopy(module: { name: string; enabled: boolean; active: b
return '已开启'
}
// 所有模块列表(按 admin_menu_order 排序)
const allModules = computed(() => {
return Object.values(moduleStore.modules)
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
function compareModuleDefaultOrder(a: ModuleStatus, b: ModuleStatus) {
return a.admin_menu_order - b.admin_menu_order ||
a.display_name.localeCompare(b.display_name, 'zh-Hans') ||
a.name.localeCompare(b.name)
}
function applySavedModuleOrder(modules: ModuleStatus[], order: string[]) {
if (order.length === 0) return modules
const modulesByName = new Map(modules.map(module => [module.name, module]))
const seen = new Set<string>()
const ordered: ModuleStatus[] = []
for (const moduleName of order) {
const module = modulesByName.get(moduleName)
if (!module || seen.has(moduleName)) continue
seen.add(moduleName)
ordered.push(module)
}
for (const module of modules) {
if (!seen.has(module.name)) {
ordered.push(module)
}
}
return ordered
}
function normalizeOrderForCurrentModules(order: string[]) {
const availableNames = new Set(defaultOrderedModules.value.map(module => module.name))
return order.filter(moduleName => availableNames.has(moduleName))
}
function moveNameToTargetIndex(names: string[], draggedName: string, targetName: string) {
const fromIndex = names.indexOf(draggedName)
const targetIndex = names.indexOf(targetName)
if (fromIndex === -1 || targetIndex === -1 || fromIndex === targetIndex) return names
const next = [...names]
const [dragged] = next.splice(fromIndex, 1)
next.splice(targetIndex, 0, dragged)
return next
}
// 后端默认顺序
const defaultOrderedModules = computed(() => {
return Object.values(moduleStore.modules).sort(compareModuleDefaultOrder)
})
// 所有模块列表(应用自定义展示顺序)
const allModules = computed(() => {
return applySavedModuleOrder(defaultOrderedModules.value, moduleOrder.value)
})
const hasCustomModuleOrder = computed(() => moduleOrder.value.length > 0)
const canReorderModules = computed(() => !orderSaving.value && allModules.value.length > 1)
// 过滤后的模块列表
const filteredModules = computed(() => {
if (!searchQuery.value.trim()) {
@@ -286,7 +380,11 @@ const filteredModules = computed(() => {
async function fetchModules() {
loading.value = true
try {
await moduleStore.fetchModules()
const [, savedOrder] = await Promise.all([
moduleStore.fetchModules(),
modulesApi.getModuleManagementOrder(),
])
moduleOrder.value = normalizeOrderForCurrentModules(savedOrder)
} catch (err) {
error('获取模块列表失败')
log.error('获取模块列表失败:', err)
@@ -309,6 +407,76 @@ async function toggleModule(moduleName: string, enabled: boolean) {
}
}
async function saveModuleOrder(nextOrder: string[]) {
if (orderSaving.value) return
const previousOrder = [...moduleOrder.value]
moduleOrder.value = normalizeOrderForCurrentModules(nextOrder)
orderSaving.value = true
try {
await modulesApi.updateModuleManagementOrder(moduleOrder.value)
success('模块顺序已保存')
} catch (err) {
moduleOrder.value = previousOrder
error(getErrorMessage(err, '保存模块顺序失败'))
log.error('保存模块顺序失败:', err)
} finally {
orderSaving.value = false
}
}
function resetModuleOrder() {
saveModuleOrder([])
}
function isInteractiveDragTarget(target: EventTarget | null) {
return target instanceof HTMLElement &&
target.closest('button, a, input, textarea, select, [role="switch"]') !== null
}
function handleModuleDragStart(moduleName: string, event: DragEvent) {
if (!canReorderModules.value || isInteractiveDragTarget(event.target)) {
event.preventDefault()
return
}
draggedModuleName.value = moduleName
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', moduleName)
}
}
function handleModuleDragEnd() {
draggedModuleName.value = null
dragOverModuleName.value = null
}
function handleModuleDragOver(moduleName: string) {
if (!canReorderModules.value || !draggedModuleName.value || draggedModuleName.value === moduleName) {
dragOverModuleName.value = null
return
}
dragOverModuleName.value = moduleName
}
function handleModuleDragLeave(moduleName: string) {
if (dragOverModuleName.value === moduleName) {
dragOverModuleName.value = null
}
}
function handleModuleDrop(targetModuleName: string) {
const draggedName = draggedModuleName.value
handleModuleDragEnd()
if (!canReorderModules.value || !draggedName || draggedName === targetModuleName) return
const nextOrder = moveNameToTargetIndex(
allModules.value.map(module => module.name),
draggedName,
targetModuleName,
)
saveModuleOrder(nextOrder)
}
onMounted(() => {
fetchModules()
})