mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Make extension modules reorderable
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
'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-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
|
'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()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user