Explorar o código

feat: 添加权限功能

jiaxing.liao hai 4 días
pai
achega
8e7897cb08

+ 23 - 18
apps/web/src/components/Sidebar/index.vue

@@ -133,10 +133,11 @@
 <script setup lang="ts">
 import { ref, computed, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
 import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
 import { useI18n } from '@/composables/useI18n'
 import { applyTheme } from '@/theme'
-import { getSidebarBottomMenu, getSidebarMainMenu, type SidebarBottomMenuItem } from '@/config/menu'
+import { buildSidebarRouteMenus, getSidebarActionMenus } from '@/config/menu'
+import { usePermissionStore } from '@/store'
+import type { SidebarPermissionMenuItem } from '@/types/permission'
 
 import defaultLogo from '@/assets/logo.svg'
 
@@ -152,6 +153,7 @@ const logo = window?.$systemConfig?.commonConfig?.webIcon || defaultLogo
 
 const router = useRouter()
 const { t, locale } = useI18n()
+const permissionStore = usePermissionStore()
 const collapsed = ref(false)
 const showSearchDialog = ref(false)
 const showTemplateModal = ref(false)
@@ -160,12 +162,18 @@ const createModalVisible = ref(false)
 
 const sidebarMainMenu = computed(() => {
 	locale.value
-	return getSidebarMainMenu(t)
+	return buildSidebarRouteMenus(permissionStore.flatMenus, t).filter(
+		(item) => item.section === 'main'
+	)
 })
 
 const sidebarBottomMenu = computed(() => {
 	locale.value
-	return getSidebarBottomMenu(t)
+	const routeMenus = buildSidebarRouteMenus(permissionStore.flatMenus, t).filter(
+		(item) => item.section === 'bottom'
+	)
+
+	return [...routeMenus, ...getSidebarActionMenus(t)]
 })
 
 // 计算当前活跃的菜单项
@@ -173,19 +181,20 @@ const activeMenu = computed(() => router.currentRoute.value.path)
 
 const isBottomMenuActive = (path: string) => router.currentRoute.value.path === path
 
-const getBottomMenuKey = (item: SidebarBottomMenuItem) => {
+const getBottomMenuKey = (item: SidebarPermissionMenuItem) => {
 	if (item.type === 'route') return item.path
 	return item.activeKey || item.settingsName || item.action
 }
 
-const isBottomMenuItemActive = (item: SidebarBottomMenuItem) => {
+const isBottomMenuItemActive = (item: SidebarPermissionMenuItem) => {
 	if (item.type === 'route') return isBottomMenuActive(item.path)
 	if (item.action === 'template') return showTemplateModal.value
 	return false
 }
 
 const handleMainMenuSelect = (path: string) => {
-	if (path === '/chat') {
+	const item = sidebarMainMenu.value.find((menu) => menu.path === path)
+	if (item?.openMode === 'blank') {
 		window.open('#/chat', '_blank')
 		return
 	}
@@ -197,20 +206,11 @@ const toggle = () => {
 	collapsed.value = !collapsed.value
 }
 
-const createWorkflow = () => {
-	createModalVisible.value = true
-}
-
 const handleCreateSuccess = (id: string) => {
 	createModalVisible.value = false
 	window.open(`#/workflow/${id}`, '_blank')
 }
 
-const createCertificate = () => {
-	// TODO: 实现凭证创建功能
-	ElMessage.info(t('sidebar.certificateComingSoon'))
-}
-
 const handleTemplateClick = () => {
 	showTemplateModal.value = true
 }
@@ -221,9 +221,13 @@ const handleTemplateSelect = (templateId: string) => {
 	showTemplateModal.value = false
 }
 
-const handleBottomMenuClick = (event: MouseEvent, item: SidebarBottomMenuItem) => {
+const handleBottomMenuClick = (event: MouseEvent, item: SidebarPermissionMenuItem) => {
 	if (item.type === 'route') {
-		router.push(item.path)
+		if (item.openMode === 'blank') {
+			window.open(`#${item.path}`, '_blank')
+		} else {
+			router.push(item.path)
+		}
 		return
 	}
 
@@ -247,6 +251,7 @@ const handleKeyDown = (e: KeyboardEvent) => {
 }
 
 onMounted(() => {
+	void permissionStore.initializePermissions()
 	window.addEventListener('keydown', handleKeyDown)
 	// 初始化主题
 	const savedMode = localStorage.getItem('app-theme-mode') || 'light'

+ 30 - 0
apps/web/src/composables/usePermission.ts

@@ -0,0 +1,30 @@
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+
+import { usePermissionStore } from '@/store'
+
+export const usePermission = () => {
+	const route = useRoute()
+	const permissionStore = usePermissionStore()
+
+	// 默认使用当前路由声明的菜单编码,页面内按钮通常只需要传 buttonCode。
+	const currentMenuCode = computed(() => route.meta.menuCode)
+
+	const canAccessMenu = (menuCode?: string) => permissionStore.hasMenuAccess(menuCode)
+
+	const canAccessButton = (buttonCode: string, menuCode = currentMenuCode.value) =>
+		permissionStore.hasButtonAccess(menuCode, buttonCode)
+
+	// 页面初始化时可主动预加载按钮权限,v-permission 指令也会按需兜底加载。
+	const ensureCurrentPageButtons = async () => {
+		if (!currentMenuCode.value) return []
+		return permissionStore.ensureButtonPermissions(currentMenuCode.value)
+	}
+
+	return {
+		currentMenuCode,
+		canAccessMenu,
+		canAccessButton,
+		ensureCurrentPageButtons
+	}
+}

+ 90 - 72
apps/web/src/config/menu.ts

@@ -1,127 +1,145 @@
-type TranslateFn = (key: string, params?: Record<string, string | number>) => string
-
-export interface SidebarMainMenuItem {
-	path: string
-	icon: string
-	label: string
-	badge?: string
-}
-
-export interface SidebarBottomRouteItem {
-	type: 'route'
-	path: string
-	icon: string
-	label: string
-}
-
-export interface SidebarBottomActionItem {
-	type: 'action'
-	action: 'template' | 'settings'
-	icon: string
-	label: string
-	activeKey?: string
-	settingsName?: 'help' | 'settings'
-	showChevron?: boolean
-}
+import type {
+	PermissionActionMenuItem,
+	PermissionMenuDefinition,
+	PermissionMenuNode,
+	PermissionRouteMenuItem
+} from '@/types/permission'
 
-export type SidebarBottomMenuItem = SidebarBottomRouteItem | SidebarBottomActionItem
+type TranslateFn = (key: string, params?: Record<string, string | number>) => string
 
-export const getSidebarMainMenu = (t: TranslateFn): SidebarMainMenuItem[] => [
-	{
-		path: '/',
-		icon: 'home',
-		label: t('sidebar.menu.overview')
-	},
+// 后端返回菜单编码,前端在这里维护菜单编码到路由、图标和展示分区的映射。
+export const permissionMenuDefinitions: PermissionMenuDefinition[] = [
 	{
+		menuCode: 'sys_ai_workflow',
 		path: '/workflow',
 		icon: 'workflow',
-		label: t('sidebar.menu.orchestration')
+		section: 'main',
+		titleKey: 'sidebar.menu.orchestration'
 	},
 	{
+		menuCode: 'sys_ai_knowledge_base',
 		path: '/knowledge',
 		icon: 'book',
-		label: t('sidebar.menu.knowledge')
+		section: 'main',
+		titleKey: 'sidebar.menu.knowledge'
 	},
 	{
+		menuCode: 'sys_ai_agent',
 		path: '/agent',
 		icon: 'platForm',
-		label: t('sidebar.menu.management')
+		section: 'main',
+		titleKey: 'sidebar.menu.management'
 	},
 	{
+		menuCode: 'sys_ai_chat',
 		path: '/chat',
 		icon: 'chatMessage',
-		label: t('sidebar.menu.chat')
-	}
-]
-
-export const getSidebarBottomMenu = (t: TranslateFn): SidebarBottomMenuItem[] => [
+		section: 'main',
+		titleKey: 'sidebar.menu.chat',
+		openMode: 'blank'
+	},
 	{
-		type: 'route',
+		menuCode: 'sys_ai_ollama',
 		path: '/ollama',
 		icon: 'ollama',
-		label: t('sidebar.menu.ollama')
+		section: 'bottom',
+		titleKey: 'sidebar.menu.ollama'
 	},
 	{
-		type: 'route',
+		menuCode: 'sys_ai_models',
 		path: '/models',
 		icon: 'model',
-		label: t('sidebar.menu.models')
+		section: 'bottom',
+		titleKey: 'sidebar.menu.models'
 	},
 	{
-		type: 'route',
+		menuCode: 'sys_ai_web_search',
 		path: '/web-search',
 		icon: 'websearch',
-		label: t('sidebar.menu.webSearch')
+		section: 'bottom',
+		titleKey: 'sidebar.menu.webSearch'
 	},
 	{
-		type: 'route',
+		menuCode: 'sys_ai_storage',
 		path: '/storage',
 		icon: 'storage',
-		label: t('sidebar.menu.storage')
+		section: 'bottom',
+		titleKey: 'sidebar.menu.storage'
 	},
 	{
-		type: 'route',
+		menuCode: 'sys_ai_vector_store',
 		path: '/vector-store',
 		icon: 'vector',
-		label: t('sidebar.menu.vectorStore')
+		section: 'bottom',
+		titleKey: 'sidebar.menu.vectorStore'
 	},
 	{
-		type: 'route',
+		menuCode: 'sys_ai_mcp',
 		path: '/mcp',
 		icon: 'mcp',
-		label: t('sidebar.menu.mcp')
+		section: 'bottom',
+		titleKey: 'sidebar.menu.mcp'
 	},
 	{
-		type: 'route',
+		menuCode: 'sys_ai_prompts',
 		path: '/prompts',
 		icon: 'prompts',
-		label: t('sidebar.menu.prompts')
+		section: 'bottom',
+		titleKey: 'sidebar.menu.prompts'
 	},
 	{
-		type: 'route',
+		menuCode: 'sys_ai_skills',
 		path: '/skills',
 		icon: 'skills',
-		label: t('sidebar.menu.skills')
+		section: 'bottom',
+		titleKey: 'sidebar.menu.skills'
 	},
-	// {
-	// 	type: 'action',
-	// 	action: 'template',
-	// 	icon: 'box',
-	// 	label: t('sidebar.menu.templates'),
-	// 	activeKey: 'template'
-	// },
-	// {
-	// 	type: 'route',
-	// 	path: '/statistics',
-	// 	icon: 'line',
-	// 	label: t('sidebar.menu.statistics')
-	// },
 	{
-		type: 'route',
+		menuCode: 'sys_ai_exec_logs',
 		path: '/execution',
 		icon: 'play',
-		label: t('sidebar.menu.execution')
-	},
+		section: 'bottom',
+		titleKey: 'sidebar.menu.execution'
+	}
+]
+
+const permissionMenuDefinitionMap = new Map(
+	permissionMenuDefinitions.map((definition) => [definition.menuCode, definition])
+)
+
+export const resolvePermissionMenuDefinition = (menuCode?: string) => {
+	if (!menuCode) return undefined
+	return permissionMenuDefinitionMap.get(menuCode)
+}
+
+export const resolveMenuRoutePath = (menuCode?: string) =>
+	resolvePermissionMenuDefinition(menuCode)?.path
+
+// 侧边栏只渲染后端已授权且前端有路由映射的菜单。
+export const buildSidebarRouteMenus = (
+	menus: PermissionMenuNode[],
+	t: TranslateFn
+): PermissionRouteMenuItem[] =>
+	menus
+		.map((menu) => {
+			const definition = resolvePermissionMenuDefinition(menu.code)
+			if (!definition) return null
+
+			return {
+				type: 'route' as const,
+				menuCode: menu.code,
+				path: definition.path,
+				icon: definition.icon,
+				label: menu.name || t(definition.titleKey),
+				section: definition.section,
+				openMode: definition.openMode || 'route',
+				menuIndex: menu.menuIndex
+			}
+		})
+		.filter((item): item is PermissionRouteMenuItem => item !== null)
+		.sort((left, right) => left.menuIndex - right.menuIndex)
+
+export const getSidebarActionMenus = (t: TranslateFn): PermissionActionMenuItem[] => [
 	{
 		type: 'action',
 		action: 'settings',

+ 71 - 0
apps/web/src/directives/permission.ts

@@ -0,0 +1,71 @@
+import type { App, Directive, DirectiveBinding } from 'vue'
+import router from '@/router'
+
+import { pinia, usePermissionStore } from '@/store'
+import type { PermissionDirectiveValue } from '@/types/permission'
+
+type PermissionHTMLElement = HTMLElement & {
+	__permissionDisplay?: string
+}
+
+// 支持三种写法:v-permission="'add'"、v-permission="['menu', 'add']"、对象形式。
+const resolvePermissionBinding = (binding: DirectiveBinding<PermissionDirectiveValue>) => {
+	const currentMenuCode =
+		typeof router.currentRoute.value.meta.menuCode === 'string'
+			? router.currentRoute.value.meta.menuCode
+			: undefined
+
+	if (typeof binding.value === 'string') {
+		return {
+			menuCode: currentMenuCode,
+			buttonCode: binding.value
+		}
+	}
+
+	if (Array.isArray(binding.value)) {
+		return {
+			menuCode: binding.value[0],
+			buttonCode: binding.value[1]
+		}
+	}
+
+	return {
+		menuCode: binding.value?.menuCode || currentMenuCode,
+		buttonCode: binding.value?.buttonCode
+	}
+}
+
+const updatePermissionVisibility = async (
+	el: PermissionHTMLElement,
+	binding: DirectiveBinding<PermissionDirectiveValue>
+) => {
+	if (el.__permissionDisplay === undefined) {
+		el.__permissionDisplay = el.style.display
+	}
+
+	const { menuCode, buttonCode } = resolvePermissionBinding(binding)
+	if (!menuCode || !buttonCode) {
+		el.style.display = 'none'
+		return
+	}
+
+	const permissionStore = usePermissionStore(pinia)
+	await permissionStore.ensureButtonPermissions(menuCode)
+
+	const allowed = permissionStore.hasButtonAccess(menuCode, buttonCode)
+	// 自定义指令只负责隐藏无权限按钮,不改变按钮原有业务逻辑。
+	el.style.display = allowed ? el.__permissionDisplay || '' : 'none'
+}
+
+const permissionDirective: Directive<PermissionHTMLElement, PermissionDirectiveValue> = {
+	mounted(el, binding) {
+		void updatePermissionVisibility(el, binding)
+	},
+	updated(el, binding) {
+		void updatePermissionVisibility(el, binding)
+	}
+}
+
+export const installPermissionDirective = (app: App) => {
+	app.directive('permission', permissionDirective)
+}

+ 2 - 0
apps/web/src/main.ts

@@ -13,6 +13,7 @@ import { initTheme } from '@/theme'
 import { lightTheme } from '@/theme/light'
 import { darkTheme } from '@/theme/dark'
 import { initializeCustomNodes } from '@/nodes/custom/initialize-custom-nodes'
+import { installPermissionDirective } from '@/directives/permission'
 import '@/api/interceptor'
 // import 'monaco-editor/esm/vs/editor/editor.main.css';
 initTheme(lightTheme, darkTheme)
@@ -26,6 +27,7 @@ const bootstrap = async () => {
 	app.use(store)
 	app.use(router)
 	app.use(ElementPlus)
+	installPermissionDirective(app)
 
 	app.provide('i18n', i18n)
 	app.config.globalProperties.$t = (key: string) => i18n.t(key)

+ 54 - 16
apps/web/src/router/index.ts

@@ -2,6 +2,8 @@ import { createRouter, createWebHashHistory } from 'vue-router'
 import NProgress from 'nprogress'
 import 'nprogress/nprogress.css'
 
+import { usePermissionStore } from '@/store'
+
 /**页面组件 */
 const MainLayout = () => import('@/layouts/MainLayout.vue')
 const Dashboard = () => import('@/views/Dashboard.vue')
@@ -54,12 +56,14 @@ const routes = [
 			{
 				path: 'execution',
 				name: 'WorkflowExecution',
-				component: WorkflowExecution
+				component: WorkflowExecution,
+				meta: { menuCode: 'sys_ai_exec_logs' }
 			},
 			{
 				path: 'agent',
 				name: 'FlowManagement',
-				component: AgentManager
+				component: AgentManager,
+				meta: { menuCode: 'sys_ai_agent' }
 			},
 			{
 				path: 'quick-start',
@@ -94,52 +98,62 @@ const routes = [
 			{
 				path: 'model',
 				name: 'ModelManager',
-				component: ModelManager
+				component: ModelManager,
+				meta: { menuCode: 'sys_ai_models' }
 			},
 			{
 				path: 'models',
 				name: 'ModelPage',
-				component: ModelPage
+				component: ModelPage,
+				meta: { menuCode: 'sys_ai_models' }
 			},
 			{
 				path: 'ollama',
 				name: 'OllamaPage',
-				component: OllamaPage
+				component: OllamaPage,
+				meta: { menuCode: 'sys_ai_ollama' }
 			},
 			{
 				path: 'knowledge',
 				name: 'KnowledgeManager',
-				component: KnowledgeManager
+				component: KnowledgeManager,
+				meta: { menuCode: 'sys_ai_knowledge_base' }
 			},
 			{
 				path: 'prompts',
 				name: 'PromptPage',
-				component: PromptPage
+				component: PromptPage,
+				meta: { menuCode: 'sys_ai_prompts' }
 			},
 			{
 				path: 'storage',
 				name: 'StoragePage',
-				component: StoragePage
+				component: StoragePage,
+				meta: { menuCode: 'sys_ai_storage' }
 			},
 			{
 				path: 'vector-store',
 				name: 'VectorStorePage',
-				component: VectorStorePage
+				component: VectorStorePage,
+				meta: { menuCode: 'sys_ai_vector_store' }
 			},
 			{
 				path: 'web-search',
 				name: 'WebSearchPage',
-				component: WebSearchPage
+				component: WebSearchPage,
+				meta: { menuCode: 'sys_ai_web_search' }
 			},
 			{
 				path: 'mcp',
 				name: 'McpPage',
-				component: McpPage
+				component: McpPage,
+				meta: { menuCode: 'sys_ai_mcp' }
 			},
 			{
 				path: 'skills',
 				name: 'SkillsPage',
-				component: SkillsPage
+				component: SkillsPage,
+				meta: { menuCode: 'sys_ai_skills' }
 			},
 			{
 				path: 'workspace',
@@ -149,7 +163,8 @@ const routes = [
 			{
 				path: '/workflow',
 				name: 'Workflow',
-				component: FlowManagement
+				component: FlowManagement,
+				meta: { menuCode: 'sys_ai_workflow' }
 			}
 		]
 	},
@@ -161,7 +176,8 @@ const routes = [
 	{
 		path: '/chat',
 		name: 'Chat',
-		component: Chat
+		component: Chat,
+		meta: { menuCode: 'sys_ai_chat' }
 	}
 ]
 
@@ -170,9 +186,31 @@ const router = createRouter({
 	routes
 })
 
-router.beforeEach((_to, _from, next) => {
+router.beforeEach(async (to, _from, next) => {
 	NProgress.start()
-	next()
+	const permissionStore = usePermissionStore()
+	const menuCode = to.meta.menuCode
+
+	if (!menuCode) {
+		next()
+		return
+	}
+
+	try {
+		await permissionStore.initializePermissions()
+
+		if (!permissionStore.hasMenuAccess(menuCode)) {
+			// 有权限菜单优先作为落点,避免无权限直达页面出现空白。
+			const fallbackPath = permissionStore.getFirstAccessibleRoutePath()
+			next(fallbackPath === to.path ? '/' : fallbackPath)
+			return
+		}
+
+		await permissionStore.ensureButtonPermissions(menuCode)
+		next()
+	} catch {
+		next()
+	}
 })
 
 router.afterEach(() => {

+ 2 - 1
apps/web/src/store/index.ts

@@ -8,6 +8,7 @@
 import type { App } from 'vue'
 import { createPinia } from 'pinia'
 import { useDashboardStore } from './modules/dashboard'
+import { usePermissionStore } from './modules/permission.store'
 import { useRunnerStore } from './modules/runner.store'
 
 const pinia = createPinia()
@@ -15,6 +16,6 @@ const pinia = createPinia()
 const store = (app: App<Element>) => {
 	app.use(pinia)
 }
-export { useDashboardStore, useRunnerStore }
+export { pinia, useDashboardStore, usePermissionStore, useRunnerStore }
 
 export default store

+ 161 - 0
apps/web/src/store/modules/permission.store.ts

@@ -0,0 +1,161 @@
+import { defineStore } from 'pinia'
+import { computed, reactive, ref } from 'vue'
+import { system } from '@repo/api-service'
+
+import { resolveMenuRoutePath } from '@/config/menu'
+import type { PermissionMenuNode } from '@/types/permission'
+
+const ROOT_MENU_CODE = 'aiRoot'
+
+const isRecord = (value: unknown): value is Record<string, unknown> =>
+	typeof value === 'object' && value !== null
+
+const toStringValue = (value: unknown) => (typeof value === 'string' ? value : '')
+
+const toNumberValue = (value: unknown) => (typeof value === 'number' ? value : 0)
+
+const readMenuChildren = (value: unknown) => {
+	if (!Array.isArray(value)) return []
+	return value.filter(isRecord)
+}
+
+// 后端 leftMenu 返回字段较多,这里只保留权限判断和侧边栏渲染需要的字段。
+const normalizeMenuNode = (node: unknown): PermissionMenuNode => ({
+	code: isRecord(node) ? toStringValue(node.code) : '',
+	name: isRecord(node) ? toStringValue(node.name) || toStringValue(node.langName) : '',
+	langName: isRecord(node) ? toStringValue(node.langName) : '',
+	link: isRecord(node) ? toStringValue(node.link) : '',
+	menuIndex: isRecord(node) ? toNumberValue(node.menuIndex) : 0,
+	menuType: isRecord(node) ? toNumberValue(node.menuType) : 0,
+	openType: isRecord(node) ? toNumberValue(node.openType) : 0,
+	children: isRecord(node) ? readMenuChildren(node.subMenuList).map(normalizeMenuNode) : []
+})
+
+const flattenMenuTree = (menus: PermissionMenuNode[]): PermissionMenuNode[] =>
+	menus.flatMap((menu) => [menu, ...flattenMenuTree(menu.children)])
+
+export const usePermissionStore = defineStore('permissionStore', () => {
+	const rootMenu = ref<PermissionMenuNode | null>(null)
+	const initialized = ref(false)
+	const initializing = ref(false)
+	// 以菜单编码为 key 缓存按钮权限,避免同一页面重复请求授权按钮列表。
+	const buttonPermissions = reactive<Record<string, string[]>>({})
+
+	// 合并并发初始化请求,避免路由守卫和 Sidebar 同时触发重复加载。
+	let initializePromise: Promise<void> | null = null
+	const buttonPermissionPromises = new Map<string, Promise<string[]>>()
+
+	const menuTree = computed(() => rootMenu.value?.children || [])
+	const flatMenus = computed(() => flattenMenuTree(menuTree.value))
+	const authorisedMenuCodes = computed(() => new Set(flatMenus.value.map((menu) => menu.code)))
+
+	const resetPermissionState = () => {
+		rootMenu.value = null
+		initialized.value = false
+		initializing.value = false
+		Object.keys(buttonPermissions).forEach((menuCode) => {
+			delete buttonPermissions[menuCode]
+		})
+		buttonPermissionPromises.clear()
+		initializePromise = null
+	}
+
+	// 拉取 aiRoot 下的授权菜单树,是菜单显示和路由权限判断的基础数据。
+	const initializePermissions = async (force = false) => {
+		if (initialized.value && !force) return
+		if (initializePromise && !force) return initializePromise
+
+		if (force) {
+			resetPermissionState()
+		}
+
+		initializing.value = true
+		initializePromise = (async () => {
+			const response = await system.postMenuLeftMenuList({
+				menuCode: ROOT_MENU_CODE
+			})
+
+			rootMenu.value = normalizeMenuNode(response?.result || {})
+			initialized.value = true
+			initializing.value = false
+		})()
+
+		try {
+			await initializePromise
+		} finally {
+			initializePromise = null
+			initializing.value = false
+		}
+	}
+
+	const hasMenuAccess = (menuCode?: string) => {
+		if (!menuCode) return true
+		return authorisedMenuCodes.value.has(menuCode)
+	}
+
+	const getButtonPermissions = (menuCode?: string) => {
+		if (!menuCode) return []
+		return buttonPermissions[menuCode] || []
+	}
+
+	// 按菜单懒加载按钮权限,匹配接口返回的 result: string[],如 ['add', 'edit']。
+	const ensureButtonPermissions = async (menuCode?: string, force = false) => {
+		if (!menuCode) return []
+		if (!hasMenuAccess(menuCode)) return []
+
+		if (!force && buttonPermissions[menuCode]) {
+			return buttonPermissions[menuCode]
+		}
+
+		const existingPromise = buttonPermissionPromises.get(menuCode)
+		if (existingPromise && !force) {
+			return existingPromise
+		}
+
+		const requestPromise = (async () => {
+			const response = await system.postMenubuttonAuthorised({ menuCode })
+			const result = Array.isArray(response?.result) ? response.result : []
+			buttonPermissions[menuCode] = result
+			return result
+		})()
+
+		buttonPermissionPromises.set(menuCode, requestPromise)
+
+		try {
+			return await requestPromise
+		} finally {
+			buttonPermissionPromises.delete(menuCode)
+		}
+	}
+
+	const hasButtonAccess = (menuCode: string | undefined, buttonCode: string) => {
+		if (!menuCode || !buttonCode) return false
+		return getButtonPermissions(menuCode).includes(buttonCode)
+	}
+
+	// 当前访问无权限时,回退到用户有权限且前端已配置路由的第一个菜单。
+	const getFirstAccessibleRoutePath = () => {
+		for (const menu of flatMenus.value) {
+			const path = resolveMenuRoutePath(menu.code)
+			if (path) return path
+		}
+
+		return '/'
+	}
+
+	return {
+		rootMenu,
+		menuTree,
+		flatMenus,
+		authorisedMenuCodes,
+		initialized,
+		initializing,
+		initializePermissions,
+		hasMenuAccess,
+		getButtonPermissions,
+		ensureButtonPermissions,
+		hasButtonAccess,
+		getFirstAccessibleRoutePath,
+		resetPermissionState
+	}
+})

+ 55 - 0
apps/web/src/types/permission.ts

@@ -0,0 +1,55 @@
+export type PermissionMenuSection = 'main' | 'bottom'
+
+export type PermissionOpenMode = 'route' | 'blank'
+
+export interface PermissionMenuNode {
+	code: string
+	name: string
+	langName?: string
+	link?: string
+	menuIndex: number
+	menuType?: number
+	openType?: number
+	children: PermissionMenuNode[]
+}
+
+export interface PermissionMenuDefinition {
+	menuCode: string
+	path: string
+	icon: string
+	section: PermissionMenuSection
+	titleKey: string
+	openMode?: PermissionOpenMode
+}
+
+export interface PermissionRouteMenuItem {
+	type: 'route'
+	menuCode: string
+	path: string
+	icon: string
+	label: string
+	badge?: string
+	section: PermissionMenuSection
+	openMode: PermissionOpenMode
+	menuIndex: number
+}
+
+export interface PermissionActionMenuItem {
+	type: 'action'
+	action: 'template' | 'settings'
+	icon: string
+	label: string
+	activeKey?: string
+	settingsName?: 'help' | 'settings'
+	showChevron?: boolean
+}
+
+export type SidebarPermissionMenuItem = PermissionRouteMenuItem | PermissionActionMenuItem
+
+export type PermissionDirectiveValue =
+	| string
+	| [menuCode: string, buttonCode: string]
+	| {
+			buttonCode: string
+			menuCode?: string
+	  }

+ 7 - 0
apps/web/src/types/router.d.ts

@@ -0,0 +1,7 @@
+import 'vue-router'
+
+declare module 'vue-router' {
+	interface RouteMeta {
+		menuCode?: string
+	}
+}

+ 8 - 0
apps/web/src/types/type.d.ts

@@ -2,6 +2,14 @@ declare module 'vue3-emoji-picker'
 declare module 'd3'
 declare module 'nprogress'
 
+interface Window {
+	$systemConfig?: {
+		commonConfig?: {
+			webIcon?: string
+		}
+	}
+}
+
 declare module 'js-yaml' {
 	export function load(yaml: string): unknown
 }

+ 5 - 5
apps/web/src/views/agent/index.vue

@@ -59,7 +59,7 @@
 					</div>
 				</div>
 				<div class="toolbar-right">
-					<el-button type="primary" @click="openCreateDrawer">
+					<el-button v-permission="'add'" type="primary" @click="openCreateDrawer">
 						<el-icon><Plus /></el-icon>
 						{{ t('pages.agent.createAgent') }}
 					</el-button>
@@ -89,12 +89,12 @@
 										<template #dropdown>
 											<el-dropdown-menu>
 												<el-dropdown-item>
-													<el-button link type="primary" @click="openEditDrawer(item.id!)">{{
+													<el-button v-permission="'edit'" link type="primary" @click="openEditDrawer(item.id!)">{{
 														t('common.edit')
 													}}</el-button>
 												</el-dropdown-item>
 												<el-dropdown-item>
-													<el-button link type="danger" @click="removeItem(item.id!)">{{
+													<el-button v-permission="'del'" link type="danger" @click="removeItem(item.id!)">{{
 														t('common.delete')
 													}}</el-button>
 												</el-dropdown-item>
@@ -124,12 +124,12 @@
 								<template #dropdown>
 									<el-dropdown-menu>
 										<el-dropdown-item>
-											<el-button link type="primary" @click="openEditDrawer(item.id!)">{{
+											<el-button v-permission="'edit'" link type="primary" @click="openEditDrawer(item.id!)">{{
 												t('common.edit')
 											}}</el-button>
 										</el-dropdown-item>
 										<el-dropdown-item>
-											<el-button link type="danger" @click="removeItem(item.id!)">{{
+											<el-button v-permission="'del'" link type="danger" @click="removeItem(item.id!)">{{
 												t('common.delete')
 											}}</el-button>
 										</el-dropdown-item>

+ 5 - 4
apps/web/src/views/knowledge/DocumentManage.vue

@@ -29,13 +29,13 @@
 				</el-button>
 			</div>
 			<div class="action-bar__right">
-				<el-button @click="openFileDrawer">
+				<el-button v-permission="{ buttonCode: 'upload_doc', menuCode: 'sys_ai_knowledge_base' }" @click="openFileDrawer">
 					<el-icon>
 						<Upload />
 					</el-icon>
 					上传文件
 				</el-button>
-				<el-button type="primary" @click="openManualDrawer">
+				<el-button v-permission="{ buttonCode: 'add_md', menuCode: 'sys_ai_knowledge_base' }" type="primary" @click="openManualDrawer">
 					<el-icon>
 						<Plus />
 					</el-icon>
@@ -90,6 +90,7 @@
 				<el-table-column label="操作" width="220" fixed="right">
 					<template #default="{ row }">
 						<el-button
+							v-permission="{ buttonCode: 'edit_doc', menuCode: 'sys_ai_knowledge_base' }"
 							v-if="row.file_type === 'manual'"
 							link
 							type="primary"
@@ -97,8 +98,8 @@
 						>
 							更新
 						</el-button>
-						<el-button link type="warning" @click="reparseKnowledge(row.id)">重新解析</el-button>
-						<el-button link type="danger" @click="removeKnowledge(row.id)">删除</el-button>
+						<el-button v-permission="{ buttonCode: 'rebuild_index', menuCode: 'sys_ai_knowledge_base' }" link type="warning" @click="reparseKnowledge(row.id)">重新解析</el-button>
+						<el-button v-permission="{ buttonCode: 'del_doc', menuCode: 'sys_ai_knowledge_base' }" link type="danger" @click="removeKnowledge(row.id)">删除</el-button>
 					</template>
 				</el-table-column>
 				<template #empty>暂无知识内容</template>

+ 3 - 3
apps/web/src/views/knowledge/KnowledgeBaseSidebar.vue

@@ -5,7 +5,7 @@
 				<div class="sidebar-title">知识库列表</div>
 			</div>
 			<div class="sidebar-actions">
-				<el-button type="primary" @click="openCreateDrawer">
+				<el-button v-permission="{ buttonCode: 'add_base', menuCode: 'sys_ai_knowledge_base' }" type="primary" @click="openCreateDrawer">
 					<el-icon>
 						<Plus />
 					</el-icon>
@@ -66,8 +66,8 @@
 					<div class="base-card__footer">
 						<span>{{ item.updateTime || item.creationTime || '-' }}</span>
 						<div class="base-card__actions" @click.stop>
-							<el-button link type="primary" @click="openEditDrawer(item.id)">编辑</el-button>
-							<el-button link type="danger" @click="removeKnowledgeBase(item.id)">删除</el-button>
+							<el-button v-permission="{ buttonCode: 'edit_base', menuCode: 'sys_ai_knowledge_base' }" link type="primary" @click="openEditDrawer(item.id)">编辑</el-button>
+							<el-button v-permission="{ buttonCode: 'del_base', menuCode: 'sys_ai_knowledge_base' }" link type="danger" @click="removeKnowledgeBase(item.id)">删除</el-button>
 						</div>
 					</div>
 				</div>

+ 4 - 4
apps/web/src/views/knowledge/QaManage.vue

@@ -16,11 +16,11 @@
 				</el-button>
 			</div>
 			<div class="flex">
-				<el-button @click="openImportModal">
+				<el-button v-permission="{ buttonCode: 'import_qa', menuCode: 'sys_ai_knowledge_base' }" @click="openImportModal">
 					<el-icon><Upload /></el-icon>
 					模版导入
 				</el-button>
-				<el-button type="primary" @click="openCreateDrawer">
+				<el-button v-permission="{ buttonCode: 'add_qa', menuCode: 'sys_ai_knowledge_base' }" type="primary" @click="openCreateDrawer">
 					<el-icon><Plus /></el-icon>
 					新增问答
 				</el-button>
@@ -61,8 +61,8 @@
 				<el-table-column label="操作" width="160" fixed="right">
 					<template #default="{ row }">
 						<el-button link type="primary" @click="openDetailDialog(row.id)">详情</el-button>
-						<el-button link type="primary" @click="openEditDrawer(row)">编辑</el-button>
-						<el-button link type="danger" @click="removeFaq(row.id)">删除</el-button>
+						<el-button v-permission="{ buttonCode: 'edit_qa', menuCode: 'sys_ai_knowledge_base' }" link type="primary" @click="openEditDrawer(row)">编辑</el-button>
+						<el-button v-permission="{ buttonCode: 'del_qa', menuCode: 'sys_ai_knowledge_base' }" link type="danger" @click="removeFaq(row.id)">删除</el-button>
 					</template>
 				</el-table-column>
 				<template #empty>暂无问答条目</template>

+ 83 - 0
apps/web/src/views/model/components/ModelDetailDialog.vue

@@ -0,0 +1,83 @@
+<template>
+	<el-dialog v-model="visible" title="模型详情" width="700px">
+		<el-descriptions v-if="model" :column="1" border>
+			<el-descriptions-item label="模型标识">{{ model.name }}</el-descriptions-item>
+			<el-descriptions-item label="显示名称">{{ model.title }}</el-descriptions-item>
+			<el-descriptions-item label="模型类型">{{ modelTypeName }}</el-descriptions-item>
+			<el-descriptions-item label="模型来源">{{ modelSourceName }}</el-descriptions-item>
+			<el-descriptions-item label="服务商">{{ model.provider }}</el-descriptions-item>
+			<el-descriptions-item label="描述">{{ model.description || '无' }}</el-descriptions-item>
+			<el-descriptions-item v-if="model.source === 'remote'" label="API地址">
+				{{ model.parameters.base_url || '无' }}
+			</el-descriptions-item>
+			<el-descriptions-item v-if="model.source === 'remote'" label="API Key">
+				{{ maskApiKey(model.parameters.api_key) }}
+			</el-descriptions-item>
+			<el-descriptions-item v-if="model.source === 'remote'" label="连接测试">
+				<div class="model-check-box">
+					<el-button type="primary" plain :loading="checkLoading" @click="$emit('check')">
+						测试连接
+					</el-button>
+					<el-alert
+						v-if="checkResult.message"
+						:type="checkResult.success ? 'success' : 'error'"
+						:title="checkResult.message"
+						:closable="false"
+						show-icon
+					/>
+				</div>
+			</el-descriptions-item>
+			<el-descriptions-item label="创建时间">{{ model.creationTime }}</el-descriptions-item>
+			<el-descriptions-item label="更新时间">{{ model.updateTime }}</el-descriptions-item>
+		</el-descriptions>
+		<template #footer>
+			<el-button @click="visible = false">关闭</el-button>
+		</template>
+	</el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { ModelDetail } from '../types'
+
+const props = defineProps<{
+	modelValue: boolean
+	model: ModelDetail | null
+	checkLoading: boolean
+	checkResult: {
+		success: boolean
+		message: string
+	}
+	getModelTypeName: (type?: string) => string
+}>()
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', value: boolean): void
+	(e: 'check'): void
+}>()
+
+const visible = computed({
+	get: () => props.modelValue,
+	set: (value) => emit('update:modelValue', value)
+})
+
+const modelTypeName = computed(() => props.getModelTypeName(props.model?.type))
+const modelSourceName = computed(() =>
+	props.model?.source === 'local' ? '本地Ollama' : '远程API'
+)
+
+function maskApiKey(key: string) {
+	if (!key) return '无'
+	if (key.length <= 8) return '****'
+	return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
+}
+</script>
+
+<style scoped lang="less">
+.model-check-box {
+	width: 100%;
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+</style>

+ 353 - 0
apps/web/src/views/model/components/ModelEditDrawer.vue

@@ -0,0 +1,353 @@
+<template>
+	<el-drawer
+		v-model="visible"
+		direction="rtl"
+		:title="currentModelId ? '编辑模型' : '新建模型'"
+		width="700px"
+	>
+		<el-form
+			ref="formRef"
+			:model="modelForm"
+			:rules="modelRules"
+			label-width="120px"
+			label-position="top"
+		>
+			<el-form-item label="模型来源" prop="source">
+				<el-radio-group v-model="modelForm.source" @change="$emit('source-change')">
+					<el-radio label="local">本地Ollama</el-radio>
+					<el-radio label="remote">服务商</el-radio>
+				</el-radio-group>
+			</el-form-item>
+
+			<el-form-item v-if="modelForm.source === 'remote'" label="模型类型" prop="type">
+				<el-select
+					v-model="modelForm.type"
+					placeholder="请选择模型类型"
+					@change="$emit('type-change')"
+				>
+					<el-option label="对话模型" value="KnowledgeQA" />
+					<el-option label="Embedding模型" value="Embedding" />
+					<el-option label="Rerank模型" value="Rerank" />
+					<el-option label="视觉模型" value="VLLM" />
+				</el-select>
+			</el-form-item>
+
+			<el-form-item v-if="modelForm.source === 'local'" label="本地模型" prop="name">
+				<el-select v-model="modelForm.name" placeholder="请选择" style="width: 100%">
+					<el-option
+						v-for="model in availableLocalModels"
+						:key="model.name"
+						:label="model.name"
+						:value="model.name"
+					/>
+				</el-select>
+			</el-form-item>
+
+			<el-form-item v-if="modelForm.source === 'remote'" label="服务商" prop="provider">
+				<el-select
+					v-model="modelForm.provider"
+					placeholder="请选择服务商"
+					style="width: 100%"
+					@change="$emit('provider-change')"
+				>
+					<el-option v-for="p in providers" :key="p.value" :label="p.label" :value="p.value" />
+				</el-select>
+			</el-form-item>
+
+			<el-form-item label="模型名称" prop="name">
+				<el-input
+					v-model="modelForm.name"
+					:readonly="modelForm.source === 'local'"
+					placeholder="如:llama3.1"
+				/>
+			</el-form-item>
+
+			<el-form-item label="显示名称" prop="title">
+				<el-input v-model="modelForm.title" placeholder="请输入显示名称" />
+			</el-form-item>
+
+			<el-form-item label="描述" prop="description">
+				<el-input
+					v-model="modelForm.description"
+					type="textarea"
+					rows="2"
+					placeholder="模型描述..."
+				/>
+			</el-form-item>
+
+			<el-form-item v-if="modelForm.source === 'remote'" label="API地址" prop="base_url">
+				<el-autocomplete
+					v-model="modelForm.base_url"
+					:fetch-suggestions="queryBaseUrlSuggestions"
+					clearable
+					placeholder="可从默认地址选择,或手动输入"
+					style="width: 100%"
+				/>
+			</el-form-item>
+
+			<el-form-item
+				v-if="modelForm.source === 'remote' && !currentModelId"
+				label="API Key"
+				prop="api_key"
+			>
+				<el-input v-model="modelForm.api_key" type="password" placeholder="输入你的API密钥" />
+			</el-form-item>
+			<el-form-item
+				v-if="modelForm.source === 'remote' && modelForm.type === 'Embedding'"
+				label="维度"
+				prop="dimension"
+			>
+				<el-input-number
+					v-model="modelForm.dimension"
+					:min="1"
+					:step="1"
+					controls-position="right"
+					style="width: 100%"
+					placeholder="请输入向量维度"
+				/>
+			</el-form-item>
+
+			<el-form-item
+				v-if="modelForm.source === 'remote' && modelForm.type === 'Embedding'"
+				label="截断Tokens"
+				prop="truncate_prompt_tokens"
+			>
+				<el-input-number
+					v-model="modelForm.truncate_prompt_tokens"
+					:min="-1"
+					:step="1"
+					controls-position="right"
+					style="width: 100%"
+					placeholder="请输入截断 token 数"
+				/>
+			</el-form-item>
+
+			<el-form-item
+				v-if="modelForm.source === 'remote' && modelForm.type === 'KnowledgeQA'"
+				label="支持视觉"
+				prop="supports_vision"
+			>
+				<el-switch v-model="modelForm.supports_vision" />
+			</el-form-item>
+
+			<el-form-item
+				v-if="modelForm.source === 'remote' && modelForm.type === 'KnowledgeQA'"
+				label="思考模式参数格式"
+				prop="thinking_control"
+			>
+				<el-select
+					v-model="modelForm.thinking_control"
+					placeholder="请选择思考模式参数格式"
+					popper-class="thinking-control-select"
+					style="width: 100%"
+				>
+					<el-option
+						v-for="option in thinkingControlOptions"
+						:key="option.value"
+						:label="option.title"
+						:value="option.value"
+					>
+						<div class="thinking-option">
+							<div class="thinking-option__title">{{ option.title }}</div>
+							<div class="thinking-option__desc">{{ option.description }}</div>
+						</div>
+					</el-option>
+				</el-select>
+				<div class="field-tip">
+					决定智能体「思考模式」开/关时如何写入 API。已尝试按厂商/模型预选,若与实际情况不符请按
+					API 文档手动修改;选「不写入」时,智能体「思考模式」开关不生效。
+				</div>
+			</el-form-item>
+
+			<el-form-item prop="custom_headers">
+				<div class="header-config">
+					<div class="header-config__top">
+						<span>自定义请求头(可选)</span>
+						<el-button type="primary" link @click="$emit('add-custom-header')">
+							<el-icon>
+								<Plus />
+							</el-icon>
+							添加请求头
+						</el-button>
+					</div>
+					<div class="header-config__desc">
+						调用远程模型API时附加的HTTP请求头,常用于鉴权、链路追踪等场景
+					</div>
+					<div class="header-config__rows">
+						<div v-for="(item, index) in customHeaderList" :key="index" class="header-config__row">
+							<el-input v-model="item.key" placeholder="Header名称" />
+							<el-input v-model="item.value" placeholder="Header值" />
+							<el-button
+								link
+								type="danger"
+								:disabled="customHeaderList.length === 1"
+								@click="$emit('remove-custom-header', index)"
+							>
+								删除
+							</el-button>
+						</div>
+					</div>
+				</div>
+			</el-form-item>
+			<el-form-item>
+				<div v-if="modelForm.source === 'remote'" class="model-check-box">
+					<el-button type="primary" plain :loading="formCheckLoading" @click="$emit('check')">
+						测试连接
+					</el-button>
+					<el-alert
+						v-if="formCheckResult.message"
+						:type="formCheckResult.success ? 'success' : 'error'"
+						:title="formCheckResult.message"
+						:closable="false"
+						show-icon
+					/>
+				</div>
+			</el-form-item>
+		</el-form>
+		<template #footer>
+			<el-button @click="visible = false">取消</el-button>
+			<el-button type="primary" :loading="submitLoading" @click="$emit('submit')">提交</el-button>
+		</template>
+	</el-drawer>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { Plus } from '@element-plus/icons-vue'
+import type {
+	ModelCreateForm,
+	ModelProvider,
+	OllamaModel,
+	ThinkingControlType
+} from '../types'
+
+const props = defineProps<{
+	modelValue: boolean
+	currentModelId: string | null
+	modelForm: ModelCreateForm
+	modelRules: Record<string, any>
+	availableLocalModels: OllamaModel[]
+	providers: ModelProvider[]
+	customHeaderList: Array<{ key: string; value: string }>
+	thinkingControlOptions: Array<{
+		title: string
+		description: string
+		value: ThinkingControlType
+	}>
+	formCheckLoading: boolean
+	formCheckResult: {
+		success: boolean
+		message: string
+	}
+	submitLoading: boolean
+	queryBaseUrlSuggestions: (
+		queryString: string,
+		cb: (results: Array<{ value: string }>) => void
+	) => void
+}>()
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', value: boolean): void
+	(e: 'source-change'): void
+	(e: 'type-change'): void
+	(e: 'provider-change'): void
+	(e: 'add-custom-header'): void
+	(e: 'remove-custom-header', index: number): void
+	(e: 'check'): void
+	(e: 'submit'): void
+}>()
+
+const formRef = ref()
+
+const visible = computed({
+	get: () => props.modelValue,
+	set: (value) => emit('update:modelValue', value)
+})
+
+defineExpose({
+	validate: (...args: any[]) => formRef.value?.validate?.(...args),
+	validateField: (...args: any[]) => formRef.value?.validateField?.(...args),
+	clearValidate: (...args: any[]) => formRef.value?.clearValidate?.(...args),
+	resetFields: (...args: any[]) => formRef.value?.resetFields?.(...args)
+})
+</script>
+
+<style scoped lang="less">
+.field-tip {
+	margin-top: 8px;
+	color: var(--text-tertiary);
+	font-size: 12px;
+	line-height: 1.6;
+}
+
+.thinking-option {
+	display: flex;
+	flex-direction: column;
+	justify-content: center;
+	min-height: 54px;
+	padding: 6px 0;
+	line-height: 1.35;
+}
+
+.thinking-option__title {
+	color: var(--text-primary);
+	font-size: 13px;
+	font-weight: 600;
+}
+
+.thinking-option__desc {
+	margin-top: 3px;
+	color: var(--text-tertiary);
+	font-size: 12px;
+	white-space: normal;
+}
+
+:global(.thinking-control-select .el-select-dropdown__item) {
+	height: auto;
+	min-height: 62px;
+	padding-top: 4px;
+	padding-bottom: 4px;
+}
+
+.header-config {
+	width: 100%;
+}
+
+.header-config__top {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+}
+
+.header-config__desc {
+	color: var(--text-tertiary);
+	font-size: 12px;
+	margin: 4px 0 10px;
+}
+
+.header-config__rows {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.header-config__row {
+	display: grid;
+	grid-template-columns: 1fr 1fr auto;
+	gap: 8px;
+	align-items: center;
+}
+
+.model-check-box {
+	width: 100%;
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+@media (max-width: 768px) {
+	.header-config__row {
+		grid-template-columns: 1fr;
+	}
+}
+</style>

+ 91 - 489
apps/web/src/views/model/index.vue

@@ -56,7 +56,7 @@
 						</div>
 					</div>
 					<div class="action-bar__right">
-						<el-button type="primary" @click="openCreateModel">
+						<el-button v-permission="'add'" type="primary" @click="openCreateModel">
 							<el-icon><Plus /></el-icon>
 							新建模型
 						</el-button>
@@ -96,8 +96,22 @@
 											<template #dropdown>
 												<el-dropdown-menu>
 													<el-dropdown-item @click="openDetailModel(row.id)">详情</el-dropdown-item>
-													<el-dropdown-item @click="openEditModel(row.id)">编辑</el-dropdown-item>
-													<el-dropdown-item @click="deleteModelConfirm(row.id)" divided>
+													<el-dropdown-item v-permission="'edit'" @click="openEditModel(row.id)">编辑</el-dropdown-item>
+													<el-dropdown-item
+														v-if="row.source === 'remote'"
+														v-permission="'edit'"
+														@click="updateModelCredentials(row.id)"
+													>
+														更新凭证
+													</el-dropdown-item>
+													<el-dropdown-item
+														v-if="row.source === 'remote'"
+														v-permission="'edit'"
+														@click="deleteModelCredentials(row.id)"
+													>
+														<span class="danger-text">删除凭证</span>
+													</el-dropdown-item>
+													<el-dropdown-item v-permission="'del'" @click="deleteModelConfirm(row.id)" divided>
 														<span class="danger-text">删除</span>
 													</el-dropdown-item>
 												</el-dropdown-menu>
@@ -141,270 +155,37 @@
 			</el-card>
 		</div>
 
-		<el-dialog v-model="showDetailDialog" title="模型详情" width="700px">
-			<el-descriptions :column="1" border v-if="currentDetailModel">
-				<el-descriptions-item label="模型标识">{{ currentDetailModel.name }}</el-descriptions-item>
-				<el-descriptions-item label="显示名称">{{ currentDetailModel.title }}</el-descriptions-item>
-				<el-descriptions-item label="模型类型">{{
-					getModelTypeName(currentDetailModel.type)
-				}}</el-descriptions-item>
-				<el-descriptions-item label="模型来源">{{
-					currentDetailModel.source === 'local' ? '本地Ollama' : '远程API'
-				}}</el-descriptions-item>
-				<el-descriptions-item label="服务商">{{
-					currentDetailModel.provider
-				}}</el-descriptions-item>
-				<el-descriptions-item label="描述">{{
-					currentDetailModel.description || '无'
-				}}</el-descriptions-item>
-				<el-descriptions-item v-if="currentDetailModel.source === 'remote'" label="API地址">
-					{{ currentDetailModel.parameters.base_url || '无' }}
-				</el-descriptions-item>
-				<el-descriptions-item v-if="currentDetailModel.source === 'remote'" label="API Key">
-					{{ maskApiKey(currentDetailModel.parameters.api_key) }}
-				</el-descriptions-item>
-				<el-descriptions-item v-if="currentDetailModel.source === 'remote'" label="连接测试">
-					<div class="model-check-box">
-						<el-button
-							type="primary"
-							plain
-							:loading="detailCheckLoading"
-							@click="checkCurrentDetailModel"
-						>
-							测试连接
-						</el-button>
-						<el-alert
-							v-if="detailCheckResult.message"
-							:type="detailCheckResult.success ? 'success' : 'error'"
-							:title="detailCheckResult.message"
-							:closable="false"
-							show-icon
-						/>
-					</div>
-				</el-descriptions-item>
-				<el-descriptions-item label="创建时间">{{
-					currentDetailModel.creationTime
-				}}</el-descriptions-item>
-				<el-descriptions-item label="更新时间">{{
-					currentDetailModel.updateTime
-				}}</el-descriptions-item>
-			</el-descriptions>
-			<template #footer>
-				<el-button @click="showDetailDialog = false">关闭</el-button>
-			</template>
-		</el-dialog>
-
-		<el-drawer
+		<ModelDetailDialog
+			v-model="showDetailDialog"
+			:model="currentDetailModel"
+			:check-loading="detailCheckLoading"
+			:check-result="detailCheckResult"
+			:get-model-type-name="getModelTypeName"
+			@check="checkCurrentDetailModel"
+		/>
+
+		<ModelEditDrawer
+			ref="modelFormRef"
 			v-model="showModelDialog"
-			direction="rtl"
-			:title="currentModelId ? '编辑模型' : '新建模型'"
-			width="700px"
-		>
-			<el-form
-				ref="modelFormRef"
-				:model="modelForm"
-				:rules="modelRules"
-				label-width="120px"
-				label-position="top"
-			>
-				<el-form-item label="模型来源" prop="source">
-					<el-radio-group v-model="modelForm.source" @change="handleSourceChange">
-						<el-radio label="local">本地Ollama</el-radio>
-						<el-radio label="remote">服务商</el-radio>
-					</el-radio-group>
-				</el-form-item>
-
-				<el-form-item v-if="modelForm.source === 'remote'" label="模型类型" prop="type">
-					<el-select
-						v-model="modelForm.type"
-						placeholder="请选择模型类型"
-						@change="handleTypeChange"
-					>
-						<el-option label="对话模型" value="KnowledgeQA" />
-						<el-option label="Embedding模型" value="Embedding" />
-						<el-option label="Rerank模型" value="Rerank" />
-						<el-option label="视觉模型" value="VLLM" />
-					</el-select>
-				</el-form-item>
-
-				<el-form-item v-if="modelForm.source === 'local'" label="本地模型" prop="name">
-					<el-select v-model="modelForm.name" placeholder="请选择" style="width: 100%">
-						<el-option
-							v-for="model in availableLocalModels"
-							:key="model.name"
-							:label="model.name"
-							:value="model.name"
-						/>
-					</el-select>
-				</el-form-item>
-
-				<el-form-item v-if="modelForm.source === 'remote'" label="服务商" prop="provider">
-					<el-select
-						v-model="modelForm.provider"
-						placeholder="请选择服务商"
-						@change="handleProviderChange"
-						style="width: 100%"
-					>
-						<el-option v-for="p in providers" :key="p.value" :label="p.label" :value="p.value" />
-					</el-select>
-				</el-form-item>
-
-				<el-form-item label="模型名称" prop="name">
-					<el-input
-						v-model="modelForm.name"
-						:readonly="modelForm.source === 'local'"
-						placeholder="如:llama3.1"
-					/>
-				</el-form-item>
-
-				<el-form-item label="显示名称" prop="title">
-					<el-input v-model="modelForm.title" placeholder="请输入显示名称" />
-				</el-form-item>
-
-				<el-form-item label="描述" prop="description">
-					<el-input
-						v-model="modelForm.description"
-						type="textarea"
-						rows="2"
-						placeholder="模型描述..."
-					/>
-				</el-form-item>
-
-				<el-form-item v-if="modelForm.source === 'remote'" label="API地址" prop="base_url">
-					<el-autocomplete
-						v-model="modelForm.base_url"
-						:fetch-suggestions="queryBaseUrlSuggestions"
-						clearable
-						placeholder="可从默认地址选择,或手动输入"
-						style="width: 100%"
-					/>
-				</el-form-item>
-
-				<el-form-item v-if="modelForm.source === 'remote'" label="API Key" prop="api_key">
-					<el-input v-model="modelForm.api_key" type="password" placeholder="输入你的API密钥" />
-				</el-form-item>
-				<el-form-item
-					v-if="modelForm.source === 'remote' && modelForm.type === 'Embedding'"
-					label="维度"
-					prop="dimension"
-				>
-					<el-input-number
-						v-model="modelForm.dimension"
-						:min="1"
-						:step="1"
-						controls-position="right"
-						style="width: 100%"
-						placeholder="请输入向量维度"
-					/>
-				</el-form-item>
-
-				<el-form-item
-					v-if="modelForm.source === 'remote' && modelForm.type === 'Embedding'"
-					label="截断Tokens"
-					prop="truncate_prompt_tokens"
-				>
-					<el-input-number
-						v-model="modelForm.truncate_prompt_tokens"
-						:min="-1"
-						:step="1"
-						controls-position="right"
-						style="width: 100%"
-						placeholder="请输入截断 token 数"
-					/>
-				</el-form-item>
-
-				<el-form-item
-					v-if="modelForm.source === 'remote' && modelForm.type === 'KnowledgeQA'"
-					label="支持视觉"
-					prop="supports_vision"
-				>
-					<el-switch v-model="modelForm.supports_vision" />
-				</el-form-item>
-
-				<el-form-item
-					v-if="modelForm.source === 'remote' && modelForm.type === 'KnowledgeQA'"
-					label="思考模式参数格式"
-					prop="thinking_control"
-				>
-					<el-select
-						v-model="modelForm.thinking_control"
-						placeholder="请选择思考模式参数格式"
-						popper-class="thinking-control-select"
-						style="width: 100%"
-					>
-						<el-option
-							v-for="option in thinkingControlOptions"
-							:key="option.value"
-							:label="option.title"
-							:value="option.value"
-						>
-							<div class="thinking-option">
-								<div class="thinking-option__title">{{ option.title }}</div>
-								<div class="thinking-option__desc">{{ option.description }}</div>
-							</div>
-						</el-option>
-					</el-select>
-					<div class="field-tip">
-						决定智能体「思考模式」开/关时如何写入 API。已尝试按厂商/模型预选,若与实际情况不符请按 API 文档手动修改;选「不写入」时,智能体「思考模式」开关不生效。
-					</div>
-				</el-form-item>
-
-				<el-form-item prop="custom_headers">
-					<div class="header-config">
-						<div class="header-config__top">
-							<span>自定义请求头(可选)</span>
-							<el-button type="primary" link @click="addCustomHeader">
-								<el-icon> <Plus /> </el-icon>添加请求头
-							</el-button>
-						</div>
-						<div class="header-config__desc">
-							调用远程模型API时附加的HTTP请求头,常用于鉴权、链路追踪等场景
-						</div>
-						<div class="header-config__rows">
-							<div
-								v-for="(item, index) in customHeaderList"
-								:key="index"
-								class="header-config__row"
-							>
-								<el-input v-model="item.key" placeholder="Header名称" />
-								<el-input v-model="item.value" placeholder="Header值" />
-								<el-button
-									link
-									type="danger"
-									:disabled="customHeaderList.length === 1"
-									@click="removeCustomHeader(index)"
-								>
-									删除
-								</el-button>
-							</div>
-						</div>
-					</div>
-				</el-form-item>
-				<el-form-item>
-					<div v-if="modelForm.source === 'remote'" class="model-check-box">
-						<el-button
-							type="primary"
-							plain
-							:loading="formCheckLoading"
-							@click="checkModelFormConnection"
-						>
-							测试连接
-						</el-button>
-						<el-alert
-							v-if="formCheckResult.message"
-							:type="formCheckResult.success ? 'success' : 'error'"
-							:title="formCheckResult.message"
-							:closable="false"
-							show-icon
-						/>
-					</div>
-				</el-form-item>
-			</el-form>
-			<template #footer>
-				<el-button @click="showModelDialog = false">取消</el-button>
-				<el-button type="primary" @click="submitModelForm" :loading="submitLoading">提交</el-button>
-			</template>
-		</el-drawer>
+			:current-model-id="currentModelId"
+			:model-form="modelForm"
+			:model-rules="modelRules"
+			:available-local-models="availableLocalModels"
+			:providers="providers"
+			:custom-header-list="customHeaderList"
+			:thinking-control-options="thinkingControlOptions"
+			:form-check-loading="formCheckLoading"
+			:form-check-result="formCheckResult"
+			:submit-loading="submitLoading"
+			:query-base-url-suggestions="queryBaseUrlSuggestions"
+			@source-change="handleSourceChange"
+			@type-change="handleTypeChange"
+			@provider-change="handleProviderChange"
+			@add-custom-header="addCustomHeader"
+			@remove-custom-header="removeCustomHeader"
+			@check="checkModelFormConnection"
+			@submit="submitModelForm"
+		/>
 	</div>
 </template>
 
@@ -413,6 +194,8 @@ import { computed, ref, reactive, onMounted, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Plus, Refresh, MoreFilled, Search, RefreshRight } from '@element-plus/icons-vue'
 import { aiModel, ollama } from '@repo/api-service'
+import ModelDetailDialog from './components/ModelDetailDialog.vue'
+import ModelEditDrawer from './components/ModelEditDrawer.vue'
 import type {
 	ModelItem,
 	ModelProvider,
@@ -465,7 +248,8 @@ const thinkingControlOptions: Array<{
 	},
 	{
 		title: 'thinking.type',
-		description: '火山引擎 Ark;腾讯云 LKEAP(DeepSeek V3 等,选 LKEAP 时默认此项;R1 请改「不写入」)',
+		description:
+			'火山引擎 Ark;腾讯云 LKEAP(DeepSeek V3 等,选 LKEAP 时默认此项;R1 请改「不写入」)',
 		value: 'thinking_type'
 	}
 ]
@@ -568,11 +352,6 @@ const typeLabelMap: Record<string, string> = {
 // 	return map[type]
 // }
 
-function maskApiKey(key: string) {
-	if (!key) return '无'
-	if (key.length <= 8) return '****'
-	return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
-}
 function getModelTypeName(type?: string) {
 	if (!type) return '-'
 	return typeLabelMap[type] || type
@@ -834,7 +613,11 @@ function getDefaultThinkingControl(provider?: string): ThinkingControlType {
 	) {
 		return 'thinking_type'
 	}
-	if (providerValue.includes('nvidia') || providerValue.includes('nim') || providerValue.includes('vllm')) {
+	if (
+		providerValue.includes('nvidia') ||
+		providerValue.includes('nim') ||
+		providerValue.includes('vllm')
+	) {
 		return 'chat_template_kwargs'
 	}
 	return 'none'
@@ -884,11 +667,7 @@ async function checkCurrentDetailModel() {
 	try {
 		const detail = currentDetailModel.value
 		const payload: Record<string, any> = {
-			name: detail.name,
-			type: detail.type,
-			source: detail.source,
-			provider: detail.provider,
-			api_key: detail.parameters?.api_key
+			id: detail.id
 		}
 		if (detail.type === 'Embedding') {
 			payload.truncate_prompt_tokens =
@@ -1001,6 +780,40 @@ async function deleteModelConfirm(id: string) {
 	})
 }
 
+async function updateModelCredentials(id: string) {
+	try {
+		const { value } = await ElMessageBox.prompt('请输入新的 API Key', '更新模型凭证', {
+			confirmButtonText: '确定',
+			cancelButtonText: '取消',
+			inputType: 'password',
+			inputPlaceholder: '请输入 API Key',
+			inputValidator: (value) => !!value?.trim() || '请输入 API Key'
+		})
+		await aiModel.postModelCredentials({ id, api_key: value.trim() })
+		ElMessage.success('凭证更新成功')
+		getAllModelList()
+	} catch (error) {
+		if (error !== 'cancel' && error !== 'close') {
+			ElMessage.error('凭证更新失败')
+		}
+	}
+}
+
+async function deleteModelCredentials(id: string) {
+	try {
+		await ElMessageBox.confirm('确定要删除该模型凭证吗?删除后该模型可能无法调用。', '提示', {
+			type: 'warning'
+		})
+		await aiModel.postModelDeleteCredentials({ id })
+		ElMessage.success('凭证删除成功')
+		getAllModelList()
+	} catch (error) {
+		if (error !== 'cancel' && error !== 'close') {
+			ElMessage.error('凭证删除失败')
+		}
+	}
+}
+
 watch(
 	() => modelForm.type,
 	() => {
@@ -1052,42 +865,6 @@ onMounted(() => {
 	width: 100%;
 }
 
-.field-tip {
-	margin-top: 8px;
-	color: var(--text-tertiary);
-	font-size: 12px;
-	line-height: 1.6;
-}
-
-.thinking-option {
-	display: flex;
-	flex-direction: column;
-	justify-content: center;
-	min-height: 54px;
-	padding: 6px 0;
-	line-height: 1.35;
-}
-
-.thinking-option__title {
-	color: var(--text-primary);
-	font-size: 13px;
-	font-weight: 600;
-}
-
-.thinking-option__desc {
-	margin-top: 3px;
-	color: var(--text-tertiary);
-	font-size: 12px;
-	white-space: normal;
-}
-
-:global(.thinking-control-select .el-select-dropdown__item) {
-	height: auto;
-	min-height: 62px;
-	padding-top: 4px;
-	padding-bottom: 4px;
-}
-
 .action-bar {
 	display: flex;
 	align-items: center;
@@ -1123,23 +900,6 @@ onMounted(() => {
 	width: 240px;
 }
 
-.toolbar-meta {
-	display: flex;
-	align-items: center;
-	gap: 10px;
-	flex-wrap: wrap;
-	margin-bottom: 16px;
-}
-
-.pill {
-	padding: 8px 12px;
-	border-radius: 999px;
-	border: 1px solid var(--border-light);
-	background: var(--bg-container);
-	font-size: 12px;
-	color: var(--text-secondary);
-}
-
 .pagination-wrap {
 	display: flex;
 	justify-content: flex-end;
@@ -1258,41 +1018,6 @@ onMounted(() => {
 	gap: 8px;
 }
 
-.meta-list {
-	display: grid;
-	grid-template-columns: repeat(3, minmax(0, 1fr));
-	gap: 10px;
-}
-
-.meta-item {
-	padding: 10px 12px;
-	border-radius: 14px;
-	background: var(--bg-container);
-	border: 1px solid var(--border-light);
-	display: flex;
-	flex-direction: column;
-	gap: 4px;
-	min-width: 0;
-}
-
-.meta-label {
-	font-size: 12px;
-	color: var(--text-tertiary);
-}
-
-.meta-value {
-	font-size: 13px;
-	color: var(--text-primary);
-	word-break: break-word;
-}
-
-.card-footer {
-	display: flex;
-	justify-content: flex-end;
-	gap: 6px;
-	margin-top: auto;
-}
-
 .actions {
 	display: flex;
 	justify-content: flex-end;
@@ -1329,132 +1054,9 @@ onMounted(() => {
 	grid-column: span 4;
 }
 
-.card-header {
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-	font-weight: 600;
-}
-
-pre {
-	margin: 0;
-	padding: 8px;
-	background: var(--bg-container);
-	border-radius: 4px;
-	font-size: 12px;
-}
-
-.provider-grid {
-	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
-	gap: 12px;
-}
-
-.provider-card {
-	position: relative;
-	padding: 16px;
-	border-radius: 16px;
-	border: 1px solid var(--border-light);
-	transition: all 0.2s ease;
-}
-
-.provider-card:hover {
-	transform: translateY(-2px);
-	box-shadow: var(--shadow-md);
-}
-
-.provider-card__top {
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-	gap: 10px;
-	margin-bottom: 8px;
-}
-
-.provider-card__label {
-	font-size: 18px;
-	font-weight: 700;
-	line-height: 1.15;
-	color: var(--text-primary);
-}
-
-.provider-card__desc {
-	font-size: 18px;
-	line-height: 1.5;
-	color: var(--text-secondary);
-	min-height: 72px;
-	margin-bottom: 12px;
-}
-
-.provider-card__tags {
-	display: flex;
-	flex-wrap: wrap;
-	gap: 8px;
-}
-
-.provider-card__action {
-	opacity: 0;
-	pointer-events: none;
-	transition: opacity 0.2s ease;
-}
-
-.provider-card:hover .provider-card__action {
-	opacity: 1;
-	pointer-events: auto;
-}
-
-.provider-card--tone-1,
-.provider-card--tone-2,
-.provider-card--tone-3,
-.provider-card--tone-4,
-.provider-card--tone-5 {
-	background: var(--bg-container);
-}
-
-.header-config {
-	width: 100%;
-}
-
-.header-config__top {
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-}
-
-.header-config__desc {
-	color: var(--text-tertiary);
-	font-size: 12px;
-	margin: 4px 0 10px;
-}
-
-.header-config__rows {
-	display: flex;
-	flex-direction: column;
-	gap: 8px;
-}
-
-.header-config__row {
-	display: grid;
-	grid-template-columns: 1fr 1fr auto;
-	gap: 8px;
-	align-items: center;
-}
-
-.model-check-box {
-	width: 100%;
-	display: flex;
-	flex-direction: column;
-	gap: 8px;
-}
-
 @media (max-width: 768px) {
 	.search-input {
 		width: 100%;
 	}
-
-	.meta-list,
-	.header-config__row {
-		grid-template-columns: 1fr;
-	}
 }
 </style>

+ 1 - 1
apps/web/src/views/model/types.d.ts

@@ -93,6 +93,6 @@ export interface ModelDetail {
 	source: string
 	status: string
 	title: string
-	type: ModelType
+	type: modelType
 	updateTime: string
 }

+ 2 - 2
apps/web/src/views/ollama/index.vue

@@ -59,7 +59,7 @@
 
 			<div class="action-bar">
 				<div class="toolbar-actions">
-					<el-button type="primary" @click="showDownloadDialog = true">
+					<el-button v-permission="'download'" type="primary" @click="showDownloadDialog = true">
 						<el-icon>
 							<Plus />
 						</el-icon>
@@ -135,7 +135,7 @@
 				</el-form>
 				<template #footer>
 					<el-button @click="showDownloadDialog = false">取消</el-button>
-					<el-button type="primary" @click="startDownloadModel" :loading="downloadLoading"
+					<el-button v-permission="'download'" type="primary" @click="startDownloadModel" :loading="downloadLoading"
 						>开始下载</el-button
 					>
 				</template>

+ 3 - 3
apps/web/src/views/storage/index.vue

@@ -9,7 +9,7 @@
 
 		<el-card class="list-card" v-loading="pageLoading">
 			<div class="storage-actions">
-				<el-button type="primary" :loading="initLoading" @click="handleInit">
+				<el-button v-permission="'init'" type="primary" :loading="initLoading" @click="handleInit">
 					<el-icon><SetUp /></el-icon>
 					初始化存储
 				</el-button>
@@ -42,7 +42,7 @@
 											>
 												{{ currentDefaultProvider === item.name ? '当前默认' : '设为默认' }}
 											</el-dropdown-item>
-											<el-dropdown-item @click="openEditProvider(item.name)">编辑</el-dropdown-item>
+											<el-dropdown-item v-permission="'edit'" @click="openEditProvider(item.name)">编辑</el-dropdown-item>
 										</el-dropdown-menu>
 									</template>
 								</el-dropdown>
@@ -265,7 +265,7 @@
 					>
 						测试连接
 					</el-button>
-					<el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
+					<el-button v-permission="'edit'" type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
 				</div>
 			</template>
 		</el-drawer>

+ 122 - 69
apps/web/src/views/web-search/index.vue

@@ -49,7 +49,7 @@
 					</div>
 				</div>
 				<div class="toolbar-right">
-					<el-button type="primary" @click="openCreate">
+					<el-button v-permission="'add'" type="primary" @click="openCreate">
 						<el-icon>
 							<Plus />
 						</el-icon>
@@ -73,8 +73,22 @@
 									</span>
 									<template #dropdown>
 										<el-dropdown-menu>
-											<el-dropdown-item @click="openEditById(row.id)">编辑</el-dropdown-item>
-											<el-dropdown-item @click="removeItem(row.id)" divided>
+											<el-dropdown-item v-permission="'edit'" @click="openEditById(row.id)"
+												>编辑</el-dropdown-item
+											>
+											<el-dropdown-item
+												v-permission="'edit'"
+												@click="updateCredentials(row.id)"
+											>
+												更新凭证
+											</el-dropdown-item>
+											<el-dropdown-item
+												v-permission="'edit'"
+												@click="deleteCredentials(row.id)"
+											>
+												<span class="danger-text">删除凭证</span>
+											</el-dropdown-item>
+											<el-dropdown-item v-permission="'del'" @click="removeItem(row.id)" divided>
 												<span class="danger-text">删除</span>
 											</el-dropdown-item>
 										</el-dropdown-menu>
@@ -110,69 +124,68 @@
 		</el-card>
 
 		<el-drawer
-				v-model="drawerVisible"
-				:title="currentId ? '编辑网络搜索' : '新建网络搜索'"
-				direction="rtl"
-				size="700px"
-			>
-				<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
-					<el-form-item label="服务商" prop="provider">
-						<el-select v-model="form.provider" style="width: 100%">
-							<el-option
-								v-for="item in engineOptions"
-								:key="item.id"
-								:label="item.name"
-								:value="item.id"
-							/>
-						</el-select>
-					</el-form-item>
-					<el-form-item label="名称" prop="name">
-						<el-input v-model="form.name" />
-					</el-form-item>
-					<el-form-item label="描述" prop="description">
-						<el-input v-model="form.description" type="textarea" :rows="2" />
-					</el-form-item>
-					<el-form-item label="API Key" prop="parameters.api_key">
-						<el-input v-model="form.parameters.api_key" type="password" show-password />
-					</el-form-item>
-					<div class="grid-2">
-						<el-form-item label="代理地址" prop="parameters.proxy_url">
-							<el-input v-model="form.parameters.proxy_url" />
-						</el-form-item>
-						<el-form-item label="引擎 ID" prop="parameters.engine_id">
-							<el-input v-model="form.parameters.engine_id" />
-						</el-form-item>
-					</div>
-					<el-form-item label="设为默认" prop="is_default">
-						<el-switch v-model="form.is_default" />
+			v-model="drawerVisible"
+			:title="currentId ? '编辑网络搜索' : '新建网络搜索'"
+			direction="rtl"
+			size="700px"
+		>
+			<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
+				<el-form-item label="服务商" prop="provider">
+					<el-select v-model="form.provider" style="width: 100%">
+						<el-option
+							v-for="item in engineOptions"
+							:key="item.id"
+							:label="item.name"
+							:value="item.id"
+						/>
+					</el-select>
+				</el-form-item>
+				<el-form-item label="名称" prop="name">
+					<el-input v-model="form.name" />
+				</el-form-item>
+				<el-form-item label="描述" prop="description">
+					<el-input v-model="form.description" type="textarea" :rows="2" />
+				</el-form-item>
+				<!-- 编辑不需要编辑api_key -->
+				<el-form-item v-if="!currentId" label="API Key" prop="parameters.api_key">
+					<el-input v-model="form.parameters.api_key" type="password" show-password />
+				</el-form-item>
+				<div class="grid-2">
+					<el-form-item label="代理地址" prop="parameters.proxy_url">
+						<el-input v-model="form.parameters.proxy_url" />
 					</el-form-item>
-					<el-form-item>
-						<div class="check-box">
-							<el-button :loading="checkLoading" @click="checkWithParameters">测试连接</el-button>
-							<el-alert
-								v-if="checkMessage"
-								:title="checkMessage"
-								:type="checkSuccess ? 'success' : 'error'"
-								:closable="false"
-								show-icon
-							/>
-						</div>
+					<el-form-item label="引擎 ID" prop="parameters.engine_id">
+						<el-input v-model="form.parameters.engine_id" />
 					</el-form-item>
-				</el-form>
-				<template #footer>
-					<div class="drawer-footer">
-						<el-button @click="drawerVisible = false">取消</el-button>
-						<el-button type="primary" :loading="submitLoading" @click="handleSubmit"
-							>保存</el-button
-						>
+				</div>
+				<el-form-item label="设为默认" prop="is_default">
+					<el-switch v-model="form.is_default" />
+				</el-form-item>
+				<el-form-item>
+					<div class="check-box">
+						<el-button :loading="checkLoading" @click="checkWithParameters">测试连接</el-button>
+						<el-alert
+							v-if="checkMessage"
+							:title="checkMessage"
+							:type="checkSuccess ? 'success' : 'error'"
+							:closable="false"
+							show-icon
+						/>
 					</div>
-				</template>
-			</el-drawer>
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<div class="drawer-footer">
+					<el-button @click="drawerVisible = false">取消</el-button>
+					<el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
+				</div>
+			</template>
+		</el-drawer>
 	</div>
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref } from 'vue'
+import { onMounted, reactive, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Plus, Search, MoreFilled, RefreshRight } from '@element-plus/icons-vue'
 import { resource } from '@repo/api-service'
@@ -315,16 +328,22 @@ async function checkWithParameters() {
 	checkLoading.value = true
 	checkMessage.value = ''
 	try {
-		const res = await resource.postWebSearchCheckWithParameters({
-			provider: form.provider,
-			parameters: {
-				api_key: form.parameters.api_key,
-				proxy_url: form.parameters.proxy_url,
-				engine_id: form.parameters.engine_id
-			}
-		})
-		checkSuccess.value = !!res.isSuccess
-		checkMessage.value = res.isSuccess ? '连通测试成功' : '连通测试失败'
+		if (currentId.value) {
+			const res = await resource.postWebSearchCheckById({ id: currentId.value })
+			checkSuccess.value = !!res.isSuccess
+			checkMessage.value = res.isSuccess ? '连通测试成功' : '连通测试失败'
+		} else {
+			const res = await resource.postWebSearchCheckWithParameters({
+				provider: form.provider,
+				parameters: {
+					api_key: form.parameters.api_key,
+					proxy_url: form.parameters.proxy_url,
+					engine_id: form.parameters.engine_id
+				}
+			})
+			checkSuccess.value = !!res.isSuccess
+			checkMessage.value = res.isSuccess ? '连通测试成功' : '连通测试失败'
+		}
 	} catch {
 		checkSuccess.value = false
 		checkMessage.value = '连通测试失败'
@@ -377,6 +396,40 @@ async function removeItem(id: string) {
 	}
 }
 
+async function updateCredentials(id: string) {
+	try {
+		const { value } = await ElMessageBox.prompt('请输入新的 API Key', '更新网络搜索凭证', {
+			confirmButtonText: '确定',
+			cancelButtonText: '取消',
+			inputType: 'password',
+			inputPlaceholder: '请输入 API Key',
+			inputValidator: (value) => !!value?.trim() || '请输入 API Key'
+		})
+		await resource.postWebSearchCredentials({ id, api_key: value.trim() })
+		ElMessage.success('凭证更新成功')
+		loadList(pagination.pageIndex)
+	} catch (error) {
+		if (error !== 'cancel' && error !== 'close') {
+			ElMessage.error('凭证更新失败')
+		}
+	}
+}
+
+async function deleteCredentials(id: string) {
+	try {
+		await ElMessageBox.confirm('确定要删除该网络搜索凭证吗?删除后该配置可能无法调用。', '提示', {
+			type: 'warning'
+		})
+		await resource.postWebSearchDeleteCredentials({ id })
+		ElMessage.success('凭证删除成功')
+		loadList(pagination.pageIndex)
+	} catch (error) {
+		if (error !== 'cancel' && error !== 'close') {
+			ElMessage.error('凭证删除失败')
+		}
+	}
+}
+
 onMounted(async () => {
 	await loadEngines()
 	await loadList(1)

+ 142 - 0
packages/api-service/schema/model.openapi.json

@@ -1484,6 +1484,148 @@
 				"security": []
 			}
 		},
+		"/api/ai/model/credentials": {
+			"post": {
+				"summary": "更新凭证",
+				"deprecated": false,
+				"description": "",
+				"tags": ["ai-model"],
+				"parameters": [
+					{
+						"name": "Authorization",
+						"in": "header",
+						"description": "",
+						"example": "bpm_backend_1523343531161161728",
+						"schema": {
+							"type": "string"
+						}
+					}
+				],
+				"requestBody": {
+					"content": {
+						"application/json": {
+							"schema": {
+								"type": "object",
+								"properties": {
+									"id": {
+										"type": "string"
+									},
+									"api_key": {
+										"type": "string"
+									}
+								},
+								"required": ["id", "api_key"]
+							},
+							"example": {
+								"id": "68680c68-2558-4921-ba24-3cd015881409",
+								"api_key": "sss"
+							}
+						}
+					},
+					"required": true
+				},
+				"responses": {
+					"200": {
+						"description": "",
+						"content": {
+							"application/json": {
+								"schema": {
+									"type": "object",
+									"properties": {
+										"isSuccess": {
+											"type": "boolean"
+										},
+										"code": {
+											"type": "integer"
+										},
+										"isAuthorized": {
+											"type": "boolean"
+										}
+									},
+									"required": ["isSuccess", "code", "isAuthorized"]
+								},
+								"example": {
+									"isSuccess": true,
+									"code": 1,
+									"isAuthorized": true
+								}
+							}
+						},
+						"headers": {}
+					}
+				},
+				"security": []
+			}
+		},
+		"/api/ai/model/delete_credentials": {
+			"post": {
+				"summary": "删除凭证",
+				"deprecated": false,
+				"description": "",
+				"tags": ["ai-model"],
+				"parameters": [
+					{
+						"name": "Authorization",
+						"in": "header",
+						"description": "",
+						"example": "bpm_backend_1523343531161161728",
+						"schema": {
+							"type": "string"
+						}
+					}
+				],
+				"requestBody": {
+					"content": {
+						"application/json": {
+							"schema": {
+								"type": "object",
+								"properties": {
+									"id": {
+										"type": "string"
+									}
+								},
+								"required": ["id"]
+							},
+							"example": {
+								"id": "68680c68-2558-4921-ba24-3cd015881409"
+							}
+						}
+					},
+					"required": true
+				},
+				"responses": {
+					"200": {
+						"description": "",
+						"content": {
+							"application/json": {
+								"schema": {
+									"type": "object",
+									"properties": {
+										"isSuccess": {
+											"type": "boolean"
+										},
+										"code": {
+											"type": "integer"
+										},
+										"isAuthorized": {
+											"type": "boolean"
+										}
+									},
+									"required": ["isSuccess", "code", "isAuthorized"]
+								},
+								"example": {
+									"isSuccess": true,
+									"code": 1,
+									"isAuthorized": true
+								}
+							}
+						},
+						"headers": {}
+					}
+				},
+				"security": []
+			}
+		},
 		"/api/ai/ollama/status": {
 			"post": {
 				"summary": "检查Ollama状态",

+ 142 - 0
packages/api-service/schema/resource.openapi.json

@@ -3284,6 +3284,148 @@
 				"security": []
 			}
 		},
+		"/api/ai/web-search/credentials": {
+			"post": {
+				"summary": "更新凭证",
+				"deprecated": false,
+				"description": "",
+				"tags": ["resource"],
+				"parameters": [
+					{
+						"name": "Authorization",
+						"in": "header",
+						"description": "",
+						"example": "bpm_backend_1523343531161161728",
+						"schema": {
+							"type": "string"
+						}
+					}
+				],
+				"requestBody": {
+					"content": {
+						"application/json": {
+							"schema": {
+								"type": "object",
+								"properties": {
+									"id": {
+										"type": "string"
+									},
+									"api_key": {
+										"type": "string"
+									}
+								},
+								"required": ["id", "api_key"]
+							},
+							"example": {
+								"id": "xxxx",
+								"api_key": ""
+							}
+						}
+					},
+					"required": true
+				},
+				"responses": {
+					"200": {
+						"description": "",
+						"content": {
+							"application/json": {
+								"schema": {
+									"type": "object",
+									"properties": {
+										"isSuccess": {
+											"type": "boolean"
+										},
+										"code": {
+											"type": "integer"
+										},
+										"isAuthorized": {
+											"type": "boolean"
+										}
+									},
+									"required": ["isSuccess", "code", "isAuthorized"]
+								},
+								"example": {
+									"isSuccess": true,
+									"code": 1,
+									"isAuthorized": true
+								}
+							}
+						},
+						"headers": {}
+					}
+				},
+				"security": []
+			}
+		},
+		"/api/ai/web-search/delete_credentials": {
+			"post": {
+				"summary": "删除凭证",
+				"deprecated": false,
+				"description": "",
+				"tags": ["resource"],
+				"parameters": [
+					{
+						"name": "Authorization",
+						"in": "header",
+						"description": "",
+						"example": "bpm_backend_1523343531161161728",
+						"schema": {
+							"type": "string"
+						}
+					}
+				],
+				"requestBody": {
+					"content": {
+						"application/json": {
+							"schema": {
+								"type": "object",
+								"properties": {
+									"id": {
+										"type": "string"
+									}
+								},
+								"required": ["id"]
+							},
+							"example": {
+								"id": "xxxx"
+							}
+						}
+					},
+					"required": true
+				},
+				"responses": {
+					"200": {
+						"description": "",
+						"content": {
+							"application/json": {
+								"schema": {
+									"type": "object",
+									"properties": {
+										"isSuccess": {
+											"type": "boolean"
+										},
+										"code": {
+											"type": "integer"
+										},
+										"isAuthorized": {
+											"type": "boolean"
+										}
+									},
+									"required": ["isSuccess", "code", "isAuthorized"]
+								},
+								"example": {
+									"isSuccess": true,
+									"code": 1,
+									"isAuthorized": true
+								}
+							}
+						},
+						"headers": {}
+					}
+				},
+				"security": []
+			}
+		},
 		"/api/ai/mcp/pageList": {
 			"post": {
 				"summary": "获取分页列表",

+ 41 - 0
packages/api-service/servers/model/api/aiModel.ts

@@ -56,6 +56,27 @@ export async function postModelCreate(
   )
 }
 
+/** 更新凭证 POST /api/ai/model/credentials */
+export async function postModelCredentials(
+  body: {
+    id: string
+    api_key: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{ isSuccess: boolean; code: number; isAuthorized: boolean }>(
+    '/api/ai/model/credentials',
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: body,
+      ...(options || {})
+    }
+  )
+}
+
 /** 删除模型 POST /api/ai/model/delete */
 export async function postModelOpenApiDelete(
   body: {
@@ -76,6 +97,26 @@ export async function postModelOpenApiDelete(
   )
 }
 
+/** 删除凭证 POST /api/ai/model/delete_credentials */
+export async function postModelDeleteCredentials(
+  body: {
+    id: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{ isSuccess: boolean; code: number; isAuthorized: boolean }>(
+    '/api/ai/model/delete_credentials',
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: body,
+      ...(options || {})
+    }
+  )
+}
+
 /** 模型详情 POST /api/ai/model/info */
 export async function postModelInfo(
   body: {

+ 41 - 0
packages/api-service/servers/resource/api/resource.ts

@@ -692,6 +692,27 @@ export async function postWebSearchCreate(
   )
 }
 
+/** 更新凭证 POST /api/ai/web-search/credentials */
+export async function postWebSearchCredentials(
+  body: {
+    id: string
+    api_key: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{ isSuccess: boolean; code: number; isAuthorized: boolean }>(
+    '/api/ai/web-search/credentials',
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: body,
+      ...(options || {})
+    }
+  )
+}
+
 /** 删除网络搜索厂商 POST /api/ai/web-search/delete */
 export async function postWebSearchOpenApiDelete(
   body: {
@@ -712,6 +733,26 @@ export async function postWebSearchOpenApiDelete(
   )
 }
 
+/** 删除凭证 POST /api/ai/web-search/delete_credentials */
+export async function postWebSearchDeleteCredentials(
+  body: {
+    id: string
+  },
+  options?: { [key: string]: any }
+) {
+  return request<{ isSuccess: boolean; code: number; isAuthorized: boolean }>(
+    '/api/ai/web-search/delete_credentials',
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      data: body,
+      ...(options || {})
+    }
+  )
+}
+
 /** 获取支持的引擎列表 POST /api/ai/web-search/engines */
 export async function postWebSearchEngines(body: {}, options?: { [key: string]: any }) {
   return request<{