Make extension modules reorderable

This commit is contained in:
fawney19
2026-05-20 22:45:32 +08:00
parent 754f672ee2
commit d49b0a8a45
4 changed files with 454 additions and 16 deletions

View File

@@ -1,5 +1,7 @@
import apiClient from './client' import apiClient from './client'
const MODULE_MANAGEMENT_ORDER_CONFIG_KEY = 'module_management.extension_order'
export interface ModuleStatus { export interface ModuleStatus {
name: string name: string
available: boolean available: boolean
@@ -75,6 +77,20 @@ const CHAT_PII_REDACTION_DEFAULT_CONFIG: ChatPiiRedactionConfig = {
placeholder_prefix: 'AETHER', 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[] { function cloneDefaultChatPiiRedactionRules(): ChatPiiRedactionRule[] {
return CHAT_PII_REDACTION_DEFAULT_RULES.map(rule => ({ ...rule })) return CHAT_PII_REDACTION_DEFAULT_RULES.map(rule => ({ ...rule }))
} }
@@ -189,6 +205,31 @@ export const modulesApi = {
return response.data 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> { async getChatPiiRedactionConfig(): Promise<ChatPiiRedactionConfig> {
const [enabled, rules, cacheTtlSeconds, placeholderPrefix] = await Promise.all([ const [enabled, rules, cacheTtlSeconds, placeholderPrefix] = await Promise.all([
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled), getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),

View File

@@ -9,6 +9,7 @@ import type { User as AdminUser } from '@/api/users'
import type { AdminApiKeysResponse } from '@/api/admin' import type { AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me' import type { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types' 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: 'rate_limit_enabled', value: true, description: '是否启用速率限制' },
{ key: 'default_rate_limit', value: 60, description: '默认速率限制(请求/分钟)' }, { key: 'default_rate_limit', value: 60, description: '默认速率限制(请求/分钟)' },
{ key: 'cache_enabled', value: true, 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: '代理节点指标每批次清理条数' } { 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 格式 ========== // ========== API 格式 ==========
export const MOCK_API_FORMATS = { export const MOCK_API_FORMATS = {

View File

@@ -22,6 +22,7 @@ import {
MOCK_PROVIDERS, MOCK_PROVIDERS,
MOCK_GLOBAL_MODELS, MOCK_GLOBAL_MODELS,
MOCK_SYSTEM_CONFIGS, MOCK_SYSTEM_CONFIGS,
MOCK_MODULE_STATUSES,
MOCK_API_FORMATS MOCK_API_FORMATS
} from './data' } from './data'
@@ -1290,6 +1291,13 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse({ requests: [] }) return createMockResponse({ requests: [] })
}, },
// ========== Admin: Modules ==========
'GET /api/admin/modules/status': async () => {
await delay()
requireAdmin()
return createMockResponse(MOCK_MODULE_STATUSES)
},
// ========== Admin: System ========== // ========== Admin: System ==========
'GET /api/admin/system/configs': async () => { 'GET /api/admin/system/configs': async () => {
await delay() 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 详情 // Provider 详情
registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_config, params) => { registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_config, params) => {
await delay() await delay()

View File

@@ -79,9 +79,25 @@
</div> </div>
<!-- 扩展模块 --> <!-- 扩展模块 -->
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4"> <div class="mb-4 flex items-center justify-between gap-3">
扩展模块 <h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
</h3> 扩展模块
</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"> <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" v-for="module in filteredModules"
:key="module.name" :key="module.name"
class="group relative border rounded-2xl p-6 transition-all duration-200 hover:shadow-lg" class="group relative border rounded-2xl p-6 transition-all duration-200 hover:shadow-lg"
:class="{ :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, 'bg-muted/40 border-muted': !module.available,
'border-border bg-card hover:border-primary/20': !module.active && 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"> <div class="absolute top-5 right-5">
@@ -121,7 +149,7 @@
class="w-5 h-5" class="w-5 h-5"
/> />
</div> </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"> <h4 class="font-semibold text-base truncate">
{{ module.display_name }} {{ module.display_name }}
</h4> </h4>
@@ -214,7 +242,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' 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 Button from '@/components/ui/button.vue'
import Switch from '@/components/ui/switch.vue' import Switch from '@/components/ui/switch.vue'
import Input from '@/components/ui/input.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 { BUILTIN_TOOLS } from '@/config/builtin-tools'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { getErrorMessage } from '@/types/api-error' import { getErrorMessage } from '@/types/api-error'
import { modulesApi, type ModuleStatus } from '@/api/modules'
const router = useRouter() const router = useRouter()
const { success, error } = useToast() const { success, error } = useToast()
@@ -232,6 +271,10 @@ const moduleStore = useModuleStore()
const loading = ref(false) const loading = ref(false)
const toggling = ref<Record<string, boolean>>({}) const toggling = ref<Record<string, boolean>>({})
const searchQuery = ref('') 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(() => { const filteredBuiltinTools = computed(() => {
@@ -262,12 +305,63 @@ function getModuleStatusCopy(module: { name: string; enabled: boolean; active: b
return '已开启' return '已开启'
} }
// 所有模块列表(按 admin_menu_order 排序) function compareModuleDefaultOrder(a: ModuleStatus, b: ModuleStatus) {
const allModules = computed(() => { return a.admin_menu_order - b.admin_menu_order ||
return Object.values(moduleStore.modules) a.display_name.localeCompare(b.display_name, 'zh-Hans') ||
.sort((a, b) => a.admin_menu_order - b.admin_menu_order) 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(() => { const filteredModules = computed(() => {
if (!searchQuery.value.trim()) { if (!searchQuery.value.trim()) {
@@ -286,7 +380,11 @@ const filteredModules = computed(() => {
async function fetchModules() { async function fetchModules() {
loading.value = true loading.value = true
try { try {
await moduleStore.fetchModules() const [, savedOrder] = await Promise.all([
moduleStore.fetchModules(),
modulesApi.getModuleManagementOrder(),
])
moduleOrder.value = normalizeOrderForCurrentModules(savedOrder)
} catch (err) { } catch (err) {
error('获取模块列表失败') error('获取模块列表失败')
log.error('获取模块列表失败:', err) 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(() => { onMounted(() => {
fetchModules() fetchModules()
}) })