Bladeren bron

fix: 调整按钮权限功能

jiaxing.liao 1 dag geleden
bovenliggende
commit
00fba5fb61

+ 137 - 9
apps/web/src/directives/permission.ts

@@ -1,4 +1,4 @@
-import type { App, Directive, DirectiveBinding } from 'vue'
+import { nextTick, type App, type Directive, type DirectiveBinding } from 'vue'
 import router from '@/router'
 
 import { pinia, usePermissionStore } from '@/store'
@@ -6,6 +6,46 @@ import type { PermissionDirectiveValue } from '@/types/permission'
 
 type PermissionHTMLElement = HTMLElement & {
 	__permissionDisplay?: string
+	__permissionDividerDisplay?: string
+	__permissionLabel?: string
+	__permissionTextNode?: Text
+	__permissionObserver?: MutationObserver
+	__permissionLabelConfig?: {
+		menuCode: string
+		buttonCode: string
+		label?: string
+		usePermissionName?: boolean
+	}
+}
+
+const resolveVisibilityElement = (el: PermissionHTMLElement) =>
+	(el.closest('.el-dropdown-menu__item') as PermissionHTMLElement | null) || el
+
+const resolveDropdownDivider = (el: PermissionHTMLElement) => {
+	const visibilityElement = resolveVisibilityElement(el)
+	const previousElement = visibilityElement.previousElementSibling as PermissionHTMLElement | null
+
+	return previousElement?.getAttribute('role') === 'separator' ? previousElement : undefined
+}
+
+const updateVisibility = (el: PermissionHTMLElement, visible: boolean) => {
+	const visibilityElement = resolveVisibilityElement(el)
+	const dropdownDivider = resolveDropdownDivider(el)
+
+	if (visibilityElement.__permissionDisplay === undefined) {
+		visibilityElement.__permissionDisplay = visibilityElement.style.display
+	}
+	if (dropdownDivider && dropdownDivider.__permissionDividerDisplay === undefined) {
+		dropdownDivider.__permissionDividerDisplay = dropdownDivider.style.display
+	}
+
+	visibilityElement.style.display = visible ? visibilityElement.__permissionDisplay || '' : 'none'
+
+	if (dropdownDivider) {
+		dropdownDivider.style.display = visible
+			? dropdownDivider.__permissionDividerDisplay || ''
+			: 'none'
+	}
 }
 
 // 支持三种写法:v-permission="'add'"、v-permission="['menu', 'add']"、对象形式。
@@ -31,21 +71,102 @@ const resolvePermissionBinding = (binding: DirectiveBinding<PermissionDirectiveV
 
 	return {
 		menuCode: binding.value?.menuCode || currentMenuCode,
-		buttonCode: binding.value?.buttonCode
+		buttonCode: binding.value?.buttonCode,
+		label: binding.value?.label,
+		usePermissionName: binding.value?.usePermissionName
+	}
+}
+
+const resolveTextNode = (el: PermissionHTMLElement) => {
+	if (el.__permissionTextNode && el.contains(el.__permissionTextNode)) {
+		return el.__permissionTextNode
 	}
+
+	const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
+	let node = walker.nextNode()
+
+	while (node) {
+		if (node.textContent?.trim()) {
+			el.__permissionTextNode = node as Text
+			return el.__permissionTextNode
+		}
+		node = walker.nextNode()
+	}
+
+	return undefined
+}
+
+const updateButtonLabel = (
+	el: PermissionHTMLElement,
+	menuCode: string,
+	buttonCode: string,
+	label?: string,
+	usePermissionName?: boolean
+) => {
+	const permissionStore = usePermissionStore(pinia)
+	const shouldUsePermissionName = usePermissionName ?? label === undefined
+	const resolvedLabel = shouldUsePermissionName
+		? permissionStore.getButtonPermissionName(menuCode, buttonCode, label || buttonCode)
+		: label || buttonCode
+
+	const textNode = resolveTextNode(el)
+	if (!textNode) return
+
+	if (el.__permissionLabel === resolvedLabel && textNode.textContent === resolvedLabel) return
+
+	// 只替换按钮的首个文本节点,保留前置图标和其他插槽内容不变。
+	textNode.textContent = resolvedLabel
+	el.__permissionLabel = resolvedLabel
+}
+
+const ensureButtonLabelObserver = (el: PermissionHTMLElement) => {
+	if (el.__permissionObserver) return
+
+	el.__permissionObserver = new MutationObserver(() => {
+		const config = el.__permissionLabelConfig
+		if (!config) return
+
+		updateButtonLabel(
+			el,
+			config.menuCode,
+			config.buttonCode,
+			config.label,
+			config.usePermissionName
+		)
+	})
+
+	el.__permissionObserver.observe(el, {
+		childList: true,
+		characterData: true,
+		subtree: true
+	})
+}
+
+const scheduleButtonLabelUpdate = async (
+	el: PermissionHTMLElement,
+	menuCode: string,
+	buttonCode: string,
+	label?: string,
+	usePermissionName?: boolean
+) => {
+	el.__permissionLabelConfig = { menuCode, buttonCode, label, usePermissionName }
+	ensureButtonLabelObserver(el)
+
+	updateButtonLabel(el, menuCode, buttonCode, label, usePermissionName)
+	await nextTick()
+	updateButtonLabel(el, menuCode, buttonCode, label, usePermissionName)
+	requestAnimationFrame(() => {
+		updateButtonLabel(el, menuCode, buttonCode, label, usePermissionName)
+	})
 }
 
 const updatePermissionVisibility = async (
 	el: PermissionHTMLElement,
 	binding: DirectiveBinding<PermissionDirectiveValue>
 ) => {
-	if (el.__permissionDisplay === undefined) {
-		el.__permissionDisplay = el.style.display
-	}
-
-	const { menuCode, buttonCode } = resolvePermissionBinding(binding)
+	const { menuCode, buttonCode, label, usePermissionName } = resolvePermissionBinding(binding)
 	if (!menuCode || !buttonCode) {
-		el.style.display = 'none'
+		updateVisibility(el, false)
 		return
 	}
 
@@ -54,7 +175,11 @@ const updatePermissionVisibility = async (
 
 	const allowed = permissionStore.hasButtonAccess(menuCode, buttonCode)
 	// 自定义指令只负责隐藏无权限按钮,不改变按钮原有业务逻辑。
-	el.style.display = allowed ? el.__permissionDisplay || '' : 'none'
+	updateVisibility(el, allowed)
+
+	if (allowed) {
+		void scheduleButtonLabelUpdate(el, menuCode, buttonCode, label, usePermissionName)
+	}
 }
 
 const permissionDirective: Directive<PermissionHTMLElement, PermissionDirectiveValue> = {
@@ -63,6 +188,9 @@ const permissionDirective: Directive<PermissionHTMLElement, PermissionDirectiveV
 	},
 	updated(el, binding) {
 		void updatePermissionVisibility(el, binding)
+	},
+	unmounted(el) {
+		el.__permissionObserver?.disconnect()
 	}
 }
 

+ 38 - 8
apps/web/src/store/modules/permission.store.ts

@@ -3,7 +3,7 @@ import { computed, reactive, ref } from 'vue'
 import { system } from '@repo/api-service'
 
 import { resolveMenuRoutePath } from '@/config/menu'
-import type { PermissionMenuNode } from '@/types/permission'
+import type { PermissionButtonMeta, PermissionMenuNode } from '@/types/permission'
 
 const ROOT_MENU_CODE = 'aiRoot'
 
@@ -19,6 +19,13 @@ const readMenuChildren = (value: unknown) => {
 	return value.filter(isRecord)
 }
 
+const normalizeButtonMeta = (button: unknown): PermissionButtonMeta => ({
+	...((isRecord(button) ? button : {}) as Record<string, unknown>),
+	code: isRecord(button) ? toStringValue(button.code) : '',
+	id: isRecord(button) ? toStringValue(button.id) : '',
+	name: isRecord(button) ? toStringValue(button.name) : ''
+})
+
 // 后端 leftMenu 返回字段较多,这里只保留权限判断和侧边栏渲染需要的字段。
 const normalizeMenuNode = (node: unknown): PermissionMenuNode => ({
 	code: isRecord(node) ? toStringValue(node.code) : '',
@@ -39,7 +46,9 @@ export const usePermissionStore = defineStore('permissionStore', () => {
 	const initialized = ref(false)
 	const initializing = ref(false)
 	// 以菜单编码为 key 缓存按钮权限,避免同一页面重复请求授权按钮列表。
-	const buttonPermissions = reactive<Record<string, string[]>>({})
+	// buttonPermissions 保留完整按钮元信息,buttonPermissionCodes 只保留 code 供鉴权使用。
+	const buttonPermissions = reactive<Record<string, PermissionButtonMeta[]>>({})
+	const buttonPermissionCodes = reactive<Record<string, string[]>>({})
 
 	// 合并并发初始化请求,避免路由守卫和 Sidebar 同时触发重复加载。
 	let initializePromise: Promise<void> | null = null
@@ -56,6 +65,9 @@ export const usePermissionStore = defineStore('permissionStore', () => {
 		Object.keys(buttonPermissions).forEach((menuCode) => {
 			delete buttonPermissions[menuCode]
 		})
+		Object.keys(buttonPermissionCodes).forEach((menuCode) => {
+			delete buttonPermissionCodes[menuCode]
+		})
 		buttonPermissionPromises.clear()
 		initializePromise = null
 	}
@@ -94,17 +106,31 @@ export const usePermissionStore = defineStore('permissionStore', () => {
 	}
 
 	const getButtonPermissions = (menuCode?: string) => {
+		if (!menuCode) return []
+		return buttonPermissionCodes[menuCode] || []
+	}
+
+	const getButtonPermissionMetas = (menuCode?: string) => {
 		if (!menuCode) return []
 		return buttonPermissions[menuCode] || []
 	}
 
-	// 按菜单懒加载按钮权限,匹配接口返回的 result: string[],如 ['add', 'edit']。
+	const getButtonPermissionName = (menuCode?: string, buttonCode?: string, fallback = '') => {
+		if (!menuCode || !buttonCode) return fallback
+
+		const matchedButton = getButtonPermissionMetas(menuCode).find(
+			(button) => button.code === buttonCode
+		)
+		return matchedButton?.name || fallback || buttonCode
+	}
+
+	// 按菜单懒加载按钮权限,接口可能返回对象数组,这里同时保留 name 和 code。
 	const ensureButtonPermissions = async (menuCode?: string, force = false) => {
 		if (!menuCode) return []
 		if (!hasMenuAccess(menuCode)) return []
 
-		if (!force && buttonPermissions[menuCode]) {
-			return buttonPermissions[menuCode]
+		if (!force && buttonPermissionCodes[menuCode]) {
+			return buttonPermissionCodes[menuCode]
 		}
 
 		const existingPromise = buttonPermissionPromises.get(menuCode)
@@ -113,10 +139,12 @@ export const usePermissionStore = defineStore('permissionStore', () => {
 		}
 
 		const requestPromise = (async () => {
-			const response = await system.postMenubuttonAuthorised({ menuCode })
+			const response = await system.postMenubuttonAuthorisedList({ menuCode })
 			const result = Array.isArray(response?.result) ? response.result : []
-			buttonPermissions[menuCode] = result
-			return result
+			const normalizedButtons = result.map(normalizeButtonMeta).filter((button) => button.code)
+			buttonPermissions[menuCode] = normalizedButtons
+			buttonPermissionCodes[menuCode] = normalizedButtons.map((button) => button.code)
+			return buttonPermissionCodes[menuCode]
 		})()
 
 		buttonPermissionPromises.set(menuCode, requestPromise)
@@ -153,6 +181,8 @@ export const usePermissionStore = defineStore('permissionStore', () => {
 		initializePermissions,
 		hasMenuAccess,
 		getButtonPermissions,
+		getButtonPermissionMetas,
+		getButtonPermissionName,
 		ensureButtonPermissions,
 		hasButtonAccess,
 		getFirstAccessibleRoutePath,

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

@@ -13,6 +13,13 @@ export interface PermissionMenuNode {
 	children: PermissionMenuNode[]
 }
 
+export interface PermissionButtonMeta {
+	code: string
+	id?: string
+	name: string
+	[key: string]: unknown
+}
+
 export interface PermissionMenuDefinition {
 	menuCode: string
 	path: string
@@ -52,4 +59,6 @@ export type PermissionDirectiveValue =
 	| {
 			buttonCode: string
 			menuCode?: string
+			label?: string
+			usePermissionName?: boolean
 	  }

+ 28 - 12
apps/web/src/views/agent/index.vue

@@ -89,14 +89,22 @@
 										<template #dropdown>
 											<el-dropdown-menu>
 												<el-dropdown-item>
-													<el-button v-permission="'edit'" link type="primary" @click="openEditDrawer(item.id!)">{{
-														t('common.edit')
-													}}</el-button>
+													<el-button
+														v-permission="{ buttonCode: 'edit', menuCode: 'sys_ai_agent' }"
+														link
+														type="primary"
+														@click="openEditDrawer(item.id!)"
+														>{{ t('common.edit') }}</el-button
+													>
 												</el-dropdown-item>
 												<el-dropdown-item>
-													<el-button v-permission="'del'" link type="danger" @click="removeItem(item.id!)">{{
-														t('common.delete')
-													}}</el-button>
+													<el-button
+														v-permission="{ buttonCode: 'del', menuCode: 'sys_ai_agent' }"
+														link
+														type="danger"
+														@click="removeItem(item.id!)"
+														>{{ t('common.delete') }}</el-button
+													>
 												</el-dropdown-item>
 											</el-dropdown-menu>
 										</template>
@@ -124,14 +132,22 @@
 								<template #dropdown>
 									<el-dropdown-menu>
 										<el-dropdown-item>
-											<el-button v-permission="'edit'" link type="primary" @click="openEditDrawer(item.id!)">{{
-												t('common.edit')
-											}}</el-button>
+											<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 v-permission="'del'" link type="danger" @click="removeItem(item.id!)">{{
-												t('common.delete')
-											}}</el-button>
+											<el-button
+												v-permission="'del'"
+												link
+												type="danger"
+												@click="removeItem(item.id!)"
+												>{{ t('common.delete') }}</el-button
+											>
 										</el-dropdown-item>
 									</el-dropdown-menu>
 								</template>

+ 21 - 16
apps/web/src/views/chat/index.vue

@@ -1635,17 +1635,20 @@ const handleConvCommand = (command: string | number, bizId: string) => {
 		})
 			.then(async () => {
 				try {
-					await deleteSession(bizId)
-					ElMessage.success(t('pages.chat.deleteSuccess'))
-					if (activeConversationId.value === bizId) {
-						activeConversationId.value = ''
-						messages.value = []
-					}
-					await loadConversations(true)
-					if (conversations.value.length > 0 && !activeConversationId.value) {
-						activeConversationId.value = conversations.value?.[0]?.id!
-						await loadConversationMessages(activeConversationId.value)
-						await scrollToBottom()
+					const res = await deleteSession(bizId)
+
+					if (res.isSuccess) {
+						ElMessage.success(t('pages.chat.deleteSuccess'))
+						if (activeConversationId.value === bizId) {
+							activeConversationId.value = ''
+							messages.value = []
+						}
+						await loadConversations(true)
+						if (conversations.value.length > 0 && !activeConversationId.value) {
+							activeConversationId.value = conversations.value?.[0]?.id!
+							await loadConversationMessages(activeConversationId.value)
+							await scrollToBottom()
+						}
 					}
 				} catch (error) {
 					ElMessage.error(t('common.error.network'))
@@ -1699,11 +1702,13 @@ const handleRename = async () => {
 	}
 
 	try {
-		await updateSessionName(renamingConvId.value, renameInput.value)
-		renameDialogVisible.value = false
-		ElMessage.success(t('pages.chat.renameSuccess'))
-		const conv = conversations.value.find((c) => c.id === renamingConvId.value)
-		if (conv) conv.title = renameInput.value
+		const res = await updateSessionName(renamingConvId.value, renameInput.value)
+		if (res.isSuccess) {
+			renameDialogVisible.value = false
+			ElMessage.success(t('pages.chat.renameSuccess'))
+			const conv = conversations.value.find((c) => c.id === renamingConvId.value)
+			if (conv) conv.title = renameInput.value
+		}
 	} catch (error) {
 		ElMessage.error(t('common.error.network'))
 	}

+ 22 - 6
apps/web/src/views/flow/index.vue

@@ -71,7 +71,11 @@
 					</div>
 				</div>
 				<div class="toolbar-right">
-					<el-button type="primary" @click="openCreateWorkflow">
+					<el-button
+						type="primary"
+						@click="openCreateWorkflow"
+						v-permission="{ buttonCode: 'add', menuCode: 'sys_ai_workflow' }"
+					>
 						<el-icon><Plus /></el-icon>
 						{{ t('shared.createWorkflow.title') }}
 					</el-button>
@@ -94,11 +98,23 @@
 							</span>
 							<template #dropdown>
 								<el-dropdown-menu>
-									<el-dropdown-item @click="openEditWorkflow(row)">{{
-										t('common.edit')
-									}}</el-dropdown-item>
-									<el-dropdown-item @click="confirmDeleteWorkflow(row.id)" divided>
-										<span class="danger-text">{{ t('common.delete') }}</span>
+									<el-dropdown-item>
+										<el-button
+											v-permission="{ buttonCode: 'edit', menuCode: 'sys_ai_workflow' }"
+											link
+											type="primary"
+											@click="openEditWorkflow(row)"
+											>{{ t('common.edit') }}</el-button
+										>
+									</el-dropdown-item>
+									<el-dropdown-item divided>
+										<el-button
+											v-permission="{ buttonCode: 'del', menuCode: 'sys_ai_workflow' }"
+											@click="confirmDeleteWorkflow(row.id)"
+											link
+											type="danger"
+											>{{ t('common.delete') }}</el-button
+										>
 									</el-dropdown-item>
 								</el-dropdown-menu>
 							</template>

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

@@ -470,12 +470,14 @@ async function submitManualKnowledge() {
 
 	submitLoading.value = true
 	try {
-		await knowledge.postAiKnowledgeCreateWithManual({
+		const res = await knowledge.postAiKnowledgeCreateWithManual({
 			knowledge_base_id: props.currentBase.id,
 			title: manualForm.title,
 			content: manualForm.content,
 			publish: manualForm.publish
 		})
+		if (!res.isSuccess) return
+
 		ElMessage.success(t('pages.knowledge.document.createSuccess'))
 		manualDrawerVisible.value = false
 		await fetchKnowledgeList()
@@ -530,11 +532,13 @@ async function submitEditKnowledge() {
 
 	submitLoading.value = true
 	try {
-		await knowledge.postAiKnowledgeUpdate({
+		const res = await knowledge.postAiKnowledgeUpdate({
 			id: editForm.id,
 			title: editForm.title,
 			description: editForm.description
 		})
+		if (!res.isSuccess) return
+
 		ElMessage.success(t('pages.knowledge.document.updateSuccess'))
 		editDrawerVisible.value = false
 		await fetchKnowledgeList()
@@ -558,7 +562,8 @@ async function reparseKnowledge(id?: string) {
 		.catch(() => false)
 	if (!confirmed) return
 
-	await knowledge.postAiKnowledgeReparse({ id })
+	const res = await knowledge.postAiKnowledgeReparse({ id })
+	if (!res.isSuccess) return
 	ElMessage.success(t('pages.knowledge.document.reparseSuccess'))
 	await fetchKnowledgeList()
 }
@@ -576,7 +581,8 @@ async function removeKnowledge(id?: string) {
 		.catch(() => false)
 	if (!confirmed) return
 
-	await knowledge.postAiKnowledgeOpenApiDelete({ id })
+	const res = await knowledge.postAiKnowledgeOpenApiDelete({ id })
+	if (!res.isSuccess) return
 	ElMessage.success(t('pages.knowledge.document.deleteSuccess'))
 	await fetchKnowledgeList()
 }

+ 42 - 11
apps/web/src/views/knowledge/KnowledgeBaseSidebar.vue

@@ -5,7 +5,11 @@
 				<div class="sidebar-title">{{ t('pages.knowledge.sidebar.listTitle') }}</div>
 			</div>
 			<div class="sidebar-actions">
-				<el-button v-permission="{ buttonCode: 'add_base', menuCode: 'sys_ai_knowledge_base' }" 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>
@@ -32,7 +36,9 @@
 			</el-input>
 			<el-radio-group v-model="typeFilter" size="small" @change="refreshKnowledgeBases">
 				<el-radio-button label="">{{ t('pages.knowledge.sidebar.all') }}</el-radio-button>
-				<el-radio-button label="document">{{ t('pages.knowledge.sidebar.knowledge') }}</el-radio-button>
+				<el-radio-button label="document">{{
+					t('pages.knowledge.sidebar.knowledge')
+				}}</el-radio-button>
 				<el-radio-button label="faq">{{ t('pages.knowledge.sidebar.faq') }}</el-radio-button>
 			</el-radio-group>
 		</div>
@@ -58,21 +64,41 @@
 					<div class="base-card__top">
 						<div class="base-card__title">{{ item.name }}</div>
 						<el-tag :type="item.type === 'faq' ? 'warning' : 'success'" size="small">
-							{{ item.type === 'faq' ? t('pages.knowledge.sidebar.faq') : t('pages.knowledge.sidebar.knowledge') }}
+							{{
+								item.type === 'faq'
+									? t('pages.knowledge.sidebar.faq')
+									: t('pages.knowledge.sidebar.knowledge')
+							}}
 						</el-tag>
 					</div>
-					<div class="base-card__desc">{{ item.description || t('pages.knowledge.sidebar.noDescription') }}</div>
+					<div class="base-card__desc">
+						{{ item.description || t('pages.knowledge.sidebar.noDescription') }}
+					</div>
 
 					<div class="base-card__footer">
 						<span>{{ item.updateTime || item.creationTime || '-' }}</span>
 						<div class="base-card__actions" @click.stop>
-							<el-button v-permission="{ buttonCode: 'edit_base', menuCode: 'sys_ai_knowledge_base' }" link type="primary" @click="openEditDrawer(item.id)">{{ t('common.edit') }}</el-button>
-							<el-button v-permission="{ buttonCode: 'del_base', menuCode: 'sys_ai_knowledge_base' }" link type="danger" @click="removeKnowledgeBase(item.id)">{{ t('common.delete') }}</el-button>
+							<el-button
+								v-permission="{ buttonCode: 'edit_base', menuCode: 'sys_ai_knowledge_base' }"
+								link
+								type="primary"
+								@click="openEditDrawer(item.id)"
+								>{{ t('common.edit') }}</el-button
+							>
+							<el-button
+								v-permission="{ buttonCode: 'del_base', menuCode: 'sys_ai_knowledge_base' }"
+								link
+								type="danger"
+								@click="removeKnowledgeBase(item.id)"
+								>{{ t('common.delete') }}</el-button
+							>
 						</div>
 					</div>
 				</div>
 				<div v-if="loadingMore" class="list-status">{{ t('pages.knowledge.sidebar.loading') }}</div>
-				<div v-else-if="!hasNextPage" class="list-status">{{ t('pages.knowledge.sidebar.noMore') }}</div>
+				<div v-else-if="!hasNextPage" class="list-status">
+					{{ t('pages.knowledge.sidebar.noMore') }}
+				</div>
 			</div>
 			<el-empty v-else :description="t('pages.knowledge.sidebar.noKnowledge')" />
 		</el-scrollbar>
@@ -203,14 +229,19 @@ function openEditDrawer(id: string) {
 }
 
 async function removeKnowledgeBase(id: string) {
-	const confirmed = await ElMessageBox.confirm(t('pages.knowledge.sidebar.confirmDelete'), t('pages.knowledge.sidebar.deleteTitle'), {
-		type: 'warning'
-	})
+	const confirmed = await ElMessageBox.confirm(
+		t('pages.knowledge.sidebar.confirmDelete'),
+		t('pages.knowledge.sidebar.deleteTitle'),
+		{
+			type: 'warning'
+		}
+	)
 		.then(() => true)
 		.catch(() => false)
 	if (!confirmed) return
 
-	await knowledge.postAiKnowledgeBaseOpenApiDelete({ id })
+	const res = await knowledge.postAiKnowledgeBaseOpenApiDelete({ id })
+	if (!res.isSuccess) return
 	ElMessage.success(t('pages.knowledge.sidebar.deleteSuccess'))
 	await refreshKnowledgeBases()
 }

+ 85 - 23
apps/web/src/views/knowledge/QaManage.vue

@@ -16,11 +16,18 @@
 				</el-button>
 			</div>
 			<div class="flex">
-				<el-button v-permission="{ buttonCode: 'import_qa', menuCode: 'sys_ai_knowledge_base' }" @click="openImportModal">
+				<el-button
+					v-permission="{ buttonCode: 'import_qa', menuCode: 'sys_ai_knowledge_base' }"
+					@click="openImportModal"
+				>
 					<el-icon><Upload /></el-icon>
 					{{ t('pages.knowledge.qa.templateImport') }}
 				</el-button>
-				<el-button v-permission="{ buttonCode: 'add_qa', menuCode: 'sys_ai_knowledge_base' }" 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>
 					{{ t('pages.knowledge.qa.addQa') }}
 				</el-button>
@@ -36,7 +43,11 @@
 			</template>
 
 			<el-table v-if="faqList.length || loading" :data="faqList" v-loading="loading" border>
-				<el-table-column prop="standard_question" :label="t('pages.knowledge.qa.standardQuestion')" min-width="260" />
+				<el-table-column
+					prop="standard_question"
+					:label="t('pages.knowledge.qa.standardQuestion')"
+					min-width="260"
+				/>
 				<el-table-column :label="t('pages.knowledge.qa.answer')" min-width="260">
 					<template #default="{ row }">
 						<span class="answers-preview">{{ formatAnswers(row.answers) }}</span>
@@ -57,12 +68,30 @@
 						<el-switch :model-value="!!row.is_enabled" @change="toggleFaqStatus(row, $event)" />
 					</template>
 				</el-table-column>
-				<el-table-column prop="creationTime" :label="t('pages.knowledge.qa.creationTime')" width="180" />
+				<el-table-column
+					prop="creationTime"
+					:label="t('pages.knowledge.qa.creationTime')"
+					width="180"
+				/>
 				<el-table-column :label="t('pages.knowledge.qa.actions')" width="160" fixed="right">
 					<template #default="{ row }">
-						<el-button link type="primary" @click="openDetailDialog(row.id)">{{ t('pages.knowledge.qa.detail') }}</el-button>
-						<el-button v-permission="{ buttonCode: 'edit_qa', menuCode: 'sys_ai_knowledge_base' }" link type="primary" @click="openEditDrawer(row)">{{ t('common.edit') }}</el-button>
-						<el-button v-permission="{ buttonCode: 'del_qa', menuCode: 'sys_ai_knowledge_base' }" link type="danger" @click="removeFaq(row.id)">{{ t('common.delete') }}</el-button>
+						<el-button link type="primary" @click="openDetailDialog(row.id)">{{
+							t('pages.knowledge.qa.detail')
+						}}</el-button>
+						<el-button
+							v-permission="{ buttonCode: 'edit_qa', menuCode: 'sys_ai_knowledge_base' }"
+							link
+							type="primary"
+							@click="openEditDrawer(row)"
+							>{{ t('common.edit') }}</el-button
+						>
+						<el-button
+							v-permission="{ buttonCode: 'del_qa', menuCode: 'sys_ai_knowledge_base' }"
+							link
+							type="danger"
+							@click="removeFaq(row.id)"
+							>{{ t('common.delete') }}</el-button
+						>
 					</template>
 				</el-table-column>
 				<template #empty>{{ t('pages.knowledge.qa.noContent') }}</template>
@@ -89,8 +118,14 @@
 			size="680px"
 		>
 			<el-form ref="formRef" :model="form" :rules="rules" label-position="top" label-width="100%">
-				<el-form-item :label="t('pages.knowledge.qa.standardQuestionLabel')" prop="standard_question">
-					<el-input v-model="form.standard_question" :placeholder="t('pages.knowledge.qa.standardQuestionPlaceholder')" />
+				<el-form-item
+					:label="t('pages.knowledge.qa.standardQuestionLabel')"
+					prop="standard_question"
+				>
+					<el-input
+						v-model="form.standard_question"
+						:placeholder="t('pages.knowledge.qa.standardQuestionPlaceholder')"
+					/>
 				</el-form-item>
 
 				<el-form-item>
@@ -201,7 +236,9 @@
 			<template #footer>
 				<div class="drawer-footer">
 					<el-button @click="drawerVisible = false">{{ t('common.cancel') }}</el-button>
-					<el-button type="primary" :loading="submitLoading" @click="submitForm">{{ t('common.save') }}</el-button>
+					<el-button type="primary" :loading="submitLoading" @click="submitForm">{{
+						t('common.save')
+					}}</el-button>
 				</div>
 			</template>
 		</el-drawer>
@@ -267,7 +304,11 @@
 							<div class="detail-item__label">{{ t('pages.knowledge.qa.enabledStatus') }}</div>
 							<div class="detail-item__value">
 								<el-tag :type="detailData.is_enabled ? 'success' : 'warning'">
-									{{ detailData.is_enabled ? t('pages.knowledge.qa.enabledOn') : t('pages.knowledge.qa.enabledOff') }}
+									{{
+										detailData.is_enabled
+											? t('pages.knowledge.qa.enabledOn')
+											: t('pages.knowledge.qa.enabledOff')
+									}}
 								</el-tag>
 							</div>
 						</div>
@@ -286,7 +327,11 @@
 			</div>
 		</el-dialog>
 		<!-- 导入弹窗 -->
-		<el-dialog v-model="importDialogVisible" :title="t('pages.knowledge.qa.importTitle')" width="500px">
+		<el-dialog
+			v-model="importDialogVisible"
+			:title="t('pages.knowledge.qa.importTitle')"
+			width="500px"
+		>
 			<el-form> </el-form>
 			<div class="import-modal-content">
 				<div class="import-step">
@@ -301,7 +346,10 @@
 
 				<div class="import-step">
 					<div class="step-title">{{ t('pages.knowledge.qa.importMode') }}</div>
-					<el-select v-model="importFormData.mode" :placeholder="t('pages.knowledge.qa.selectPlaceholder')">
+					<el-select
+						v-model="importFormData.mode"
+						:placeholder="t('pages.knowledge.qa.selectPlaceholder')"
+					>
 						<el-option :label="t('pages.knowledge.qa.importAppend')" value="append"></el-option>
 						<el-option :label="t('pages.knowledge.qa.importReplace')" value="replace"></el-option>
 					</el-select>
@@ -354,12 +402,16 @@
 					<el-tag :type="getImportTaskStatusTag(importTaskInfo?.status)">
 						{{ getImportTaskStatusText(importTaskInfo?.status) }}
 					</el-tag>
-					<span class="import-task__progress"> {{ t('pages.knowledge.qa.progress') }}:{{ importTaskInfo?.progress || '-' }} </span>
+					<span class="import-task__progress">
+						{{ t('pages.knowledge.qa.progress') }}:{{ importTaskInfo?.progress || '-' }}
+					</span>
 				</div>
 			</div>
 			<template #footer>
 				<div class="drawer-footer">
-					<el-button @click="handleImportTaskDialogClose">{{ t('pages.knowledge.qa.close') }}</el-button>
+					<el-button @click="handleImportTaskDialogClose">{{
+						t('pages.knowledge.qa.close')
+					}}</el-button>
 				</div>
 			</template>
 		</el-dialog>
@@ -452,7 +504,9 @@ const createDefaultForm = (): FaqForm => ({
 const form = reactive<FaqForm>(createDefaultForm())
 
 const rules = {
-	standard_question: [{ required: true, message: t('pages.knowledge.qa.standardQuestionRequired'), trigger: 'blur' }],
+	standard_question: [
+		{ required: true, message: t('pages.knowledge.qa.standardQuestionRequired'), trigger: 'blur' }
+	],
 	answers: [
 		{
 			validator: (_rule: unknown, value: string[], callback: (error?: Error) => void) => {
@@ -587,13 +641,15 @@ async function submitForm() {
 			is_enabled: form.is_enabled
 		}
 		if (form.id) {
-			await knowledge.postAiFaqUpdate({ id: form.id, ...payload })
+			const res = await knowledge.postAiFaqUpdate({ id: form.id, ...payload })
+			if (!res.isSuccess) return
 			ElMessage.success(t('pages.knowledge.qa.updateSuccess'))
 		} else {
-			await knowledge.postAiFaqCreate({
+			const res = await knowledge.postAiFaqCreate({
 				knowledge_base_id: props.currentBaseId,
 				...payload
 			})
+			if (!res.isSuccess) return
 			ElMessage.success(t('pages.knowledge.qa.createSuccess'))
 		}
 		drawerVisible.value = false
@@ -615,7 +671,7 @@ async function toggleFaqStatus(row: FaqItem, value: string | number | boolean) {
 		ElMessage.error(t('pages.knowledge.qa.detailLoadFailed'))
 		return
 	}
-	await knowledge.postAiFaqUpdate({
+	const res = await knowledge.postAiFaqUpdate({
 		id: row.id,
 		standard_question: detail.standard_question || '',
 		similar_questions: normalizeStringArray(detail.similar_questions),
@@ -623,20 +679,26 @@ async function toggleFaqStatus(row: FaqItem, value: string | number | boolean) {
 		answers: normalizeStringArray(detail.answers),
 		is_enabled: Boolean(value)
 	})
+	if (!res.isSuccess) return
 	ElMessage.success(t('pages.knowledge.qa.statusUpdated'))
 	await fetchFaqList()
 }
 
 async function removeFaq(id?: string) {
 	if (!id) return
-	const confirmed = await ElMessageBox.confirm(t('pages.knowledge.qa.confirmDelete'), t('pages.knowledge.qa.deleteTitle'), {
-		type: 'warning'
-	})
+	const confirmed = await ElMessageBox.confirm(
+		t('pages.knowledge.qa.confirmDelete'),
+		t('pages.knowledge.qa.deleteTitle'),
+		{
+			type: 'warning'
+		}
+	)
 		.then(() => true)
 		.catch(() => false)
 	if (!confirmed) return
 
-	await knowledge.postAiFaqOpenApiDelete({ id })
+	const res = await knowledge.postAiFaqOpenApiDelete({ id })
+	if (!res.isSuccess) return
 	ElMessage.success(t('pages.knowledge.qa.deleteSuccess'))
 	await refreshFaqList()
 }

+ 4 - 2
apps/web/src/views/knowledge/components/KnowledgeBaseEditModal.vue

@@ -934,13 +934,15 @@ async function submitForm() {
 	try {
 		const payload = buildPayload()
 		if (editingId.value) {
-			await knowledge.postAiKnowledgeBaseUpdate({
+			const res = await knowledge.postAiKnowledgeBaseUpdate({
 				id: editingId.value,
 				...(payload as any)
 			} as any)
+			if (!res?.isSuccess) return
 			ElMessage.success(t('pages.knowledge.editModal.updateSuccess'))
 		} else {
-			await knowledge.postAiKnowledgeBaseCreate(payload as any)
+			const res = await knowledge.postAiKnowledgeBaseCreate(payload as any)
+			if (!res?.isSuccess) return
 			ElMessage.success(t('pages.knowledge.editModal.createSuccess'))
 		}
 		drawerVisible.value = false

+ 55 - 20
apps/web/src/views/mcp/index.vue

@@ -47,7 +47,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>
@@ -57,7 +57,9 @@
 			</div>
 
 			<div class="toolbar-meta">
-				<span class="pill">{{ t('pages.mcp.totalServices', { count: pagination.totalCount }) }}</span>
+				<span class="pill">{{
+					t('pages.mcp.totalServices', { count: pagination.totalCount })
+				}}</span>
 				<span class="pill">{{ t('pages.mcp.enabledCount', { count: enabledCount }) }}</span>
 			</div>
 
@@ -77,7 +79,11 @@
 		</div> -->
 
 			<div v-loading="loading" class="grid">
-				<el-empty class="empty" v-if="!list.length && !loading" :description="t('pages.mcp.noMcp')" />
+				<el-empty
+					class="empty"
+					v-if="!list.length && !loading"
+					:description="t('pages.mcp.noMcp')"
+				/>
 				<div v-for="row in list" :key="row.id" class="card">
 					<div class="card-head">
 						<div class="card-head__top">
@@ -94,11 +100,17 @@
 									</span>
 									<template #dropdown>
 										<el-dropdown-menu>
-											<el-dropdown-item @click="checkItem(row.id!)">{{ t('pages.mcp.test') }}</el-dropdown-item>
-											<el-dropdown-item @click="openTools(row.id!)">{{ t('pages.mcp.tools') }}</el-dropdown-item>
-											<el-dropdown-item @click="openEditById(row.id!)">{{ t('common.edit') }}</el-dropdown-item>
+											<el-dropdown-item @click="checkItem(row.id!)">{{
+												t('pages.mcp.test')
+											}}</el-dropdown-item>
+											<el-dropdown-item @click="openTools(row.id!)">{{
+												t('pages.mcp.tools')
+											}}</el-dropdown-item>
+											<el-dropdown-item @click="openEditById(row.id!)">
+												<span v-permission="'edit'">{{ t('common.edit') }}</span>
+											</el-dropdown-item>
 											<el-dropdown-item divided @click="removeItem(row.id!)">
-												<span class="danger-text">{{ t('common.delete') }}</span>
+												<span v-permission="'del'" class="danger-text">{{ t('common.delete') }}</span>
 											</el-dropdown-item>
 										</el-dropdown-menu>
 									</template>
@@ -109,7 +121,9 @@
 							<el-tag :type="row.enabled ? 'success' : 'warining'" class="badge subtle">{{
 								row.enabled ? t('pages.mcp.enabled') : t('pages.mcp.disabled')
 							}}</el-tag>
-							<el-tag class="badge">{{ row.transport_type || t('pages.mcp.noTransferType') }}</el-tag>
+							<el-tag class="badge">{{
+								row.transport_type || t('pages.mcp.noTransferType')
+							}}</el-tag>
 						</div>
 					</div>
 
@@ -270,18 +284,24 @@
 			<template #footer>
 				<div class="drawer-footer">
 					<el-button @click="drawerVisible = false">{{ t('common.cancel') }}</el-button>
-					<el-button type="primary" :loading="submitLoading" @click="handleSubmit">{{ t('common.save') }}</el-button>
+					<el-button type="primary" :loading="submitLoading" @click="handleSubmit">{{
+						t('common.save')
+					}}</el-button>
 				</div>
 			</template>
 		</el-drawer>
 
 		<el-dialog v-model="detailVisible" :title="t('pages.mcp.mcpDetail')" width="720px">
 			<el-descriptions v-if="detailItem" :column="1" border>
-				<el-descriptions-item :label="t('pages.mcp.name')">{{ detailItem.name }}</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.mcp.name')">{{
+					detailItem.name
+				}}</el-descriptions-item>
 				<el-descriptions-item :label="t('pages.mcp.transferType')">{{
 					detailItem.transport_type
 				}}</el-descriptions-item>
-				<el-descriptions-item :label="t('pages.mcp.address')">{{ detailItem.url || '-' }}</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.mcp.address')">{{
+					detailItem.url || '-'
+				}}</el-descriptions-item>
 				<el-descriptions-item :label="t('pages.mcp.description')">{{
 					detailItem.description || '-'
 				}}</el-descriptions-item>
@@ -295,7 +315,12 @@
 			</el-space>
 		</el-dialog>
 
-		<el-dialog v-model="toolsVisible" :title="t('pages.mcp.mcpTools')" width="720px" class="tool-modal">
+		<el-dialog
+			v-model="toolsVisible"
+			:title="t('pages.mcp.mcpTools')"
+			width="720px"
+			class="tool-modal"
+		>
 			<div class="tool-dialog">
 				<el-input
 					v-model="toolKeyword"
@@ -405,7 +430,9 @@ const form = reactive({
 
 const rules = {
 	name: [{ required: true, message: t('pages.mcp.pleaseInputName'), trigger: 'blur' }],
-	transport_type: [{ required: true, message: t('pages.mcp.pleaseSelectTransferType'), trigger: 'change' }],
+	transport_type: [
+		{ required: true, message: t('pages.mcp.pleaseSelectTransferType'), trigger: 'change' }
+	],
 	url: [{ required: true, message: t('pages.mcp.pleaseInputAddress'), trigger: 'blur' }]
 }
 
@@ -585,7 +612,9 @@ async function checkItemByForm() {
 	try {
 		const res = await resource.postMcpCheck({ id: currentId.value })
 		checkSuccess.value = !!res.isSuccess
-		checkMessage.value = res.isSuccess ? res.result?.message || t('pages.mcp.connectSuccess') : t('pages.mcp.connectFailed')
+		checkMessage.value = res.isSuccess
+			? res.result?.message || t('pages.mcp.connectSuccess')
+			: t('pages.mcp.connectFailed')
 	} catch {
 		checkSuccess.value = false
 		checkMessage.value = t('pages.mcp.connectFailed')
@@ -615,10 +644,12 @@ async function handleSubmit() {
 				env_vars: toRecord(envList.value)
 			}
 			if (currentId.value) {
-				await resource.postMcpUpdate({ id: currentId.value, ...(payload as any) })
+				const res = await resource.postMcpUpdate({ id: currentId.value, ...(payload as any) })
+				if (!res?.isSuccess) return
 				ElMessage.success(t('pages.mcp.updateSuccess'))
 			} else {
-				await resource.postMcpCreate(payload as any)
+				const res = await resource.postMcpCreate(payload as any)
+				if (!res?.isSuccess) return
 				ElMessage.success(t('pages.mcp.createSuccess'))
 			}
 			drawerVisible.value = false
@@ -633,10 +664,14 @@ async function handleSubmit() {
 
 async function removeItem(id: string) {
 	try {
-		await ElMessageBox.confirm(t('pages.mcp.confirmDelete'), t('pages.mcp.tip'), { type: 'warning' })
-		await resource.postMcpOpenApiDelete({ id })
-		ElMessage.success(t('pages.mcp.deleteSuccess'))
-		loadList(pagination.pageIndex)
+		await ElMessageBox.confirm(t('pages.mcp.confirmDelete'), t('pages.mcp.tip'), {
+			type: 'warning'
+		})
+		const res = await resource.postMcpOpenApiDelete({ id })
+		if (res?.isSuccess) {
+			ElMessage.success(t('pages.mcp.deleteSuccess'))
+			loadList(pagination.pageIndex)
+		}
 	} catch {
 		// ignore
 	}

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

@@ -92,6 +92,21 @@
 			>
 				<el-input v-model="modelForm.api_key" type="password" :placeholder="t('pages.model.placeholders.apiKey')" />
 			</el-form-item>
+			<el-form-item v-if="modelForm.source === 'remote' && currentModelId">
+				<div class="credential-actions">
+					<el-button type="primary" plain @click="$emit('update-credentials')">
+						{{ t('pages.model.updateCredentials') }}
+					</el-button>
+					<el-button
+						v-if="hasConfiguredCredential"
+						type="danger"
+						plain
+						@click="$emit('delete-credentials')"
+					>
+						{{ t('pages.model.deleteCredentials') }}
+					</el-button>
+				</div>
+			</el-form-item>
 			<el-form-item
 				v-if="modelForm.source === 'remote' && modelForm.type === 'Embedding'"
 				:label="t('pages.model.fields.dimension')"
@@ -242,6 +257,7 @@ const props = defineProps<{
 		message: string
 	}
 	submitLoading: boolean
+	hasConfiguredCredential: boolean
 	queryBaseUrlSuggestions: (
 		queryString: string,
 		cb: (results: Array<{ value: string }>) => void
@@ -256,6 +272,8 @@ const emit = defineEmits<{
 	(e: 'add-custom-header'): void
 	(e: 'remove-custom-header', index: number): void
 	(e: 'check'): void
+	(e: 'update-credentials'): void
+	(e: 'delete-credentials'): void
 	(e: 'submit'): void
 }>()
 
@@ -340,6 +358,11 @@ defineExpose({
 	align-items: center;
 }
 
+.credential-actions {
+	display: flex;
+	gap: 8px;
+}
+
 .model-check-box {
 	width: 100%;
 	display: flex;

+ 36 - 31
apps/web/src/views/model/index.vue

@@ -100,32 +100,11 @@
 													<el-dropdown-item @click="openDetailModel(row.id)">{{
 														t('pages.model.detail')
 													}}</el-dropdown-item>
-													<el-dropdown-item v-permission="'edit'" @click="openEditModel(row.id)">{{
-														t('pages.model.edit')
-													}}</el-dropdown-item>
-													<el-dropdown-item
-														v-if="row.source === 'remote'"
-														v-permission="'edit'"
-														@click="updateModelCredentials(row.id)"
-														divided
-													>
-														{{ t('pages.model.updateCredentials') }}
-													</el-dropdown-item>
-													<el-dropdown-item
-														v-if="row.source === 'remote'"
-														v-permission="'edit'"
-														@click="deleteModelCredentials(row.id)"
-													>
-														<span class="danger-text">{{
-															t('pages.model.deleteCredentials')
-														}}</span>
+													<el-dropdown-item @click="openEditModel(row.id)">
+														<span v-permission="'edit'">{{ t('pages.model.edit') }}</span>
 													</el-dropdown-item>
-													<el-dropdown-item
-														v-permission="'del'"
-														@click="deleteModelConfirm(row.id)"
-														divided
-													>
-														<span class="danger-text">{{ t('common.delete') }}</span>
+													<el-dropdown-item @click="deleteModelConfirm(row.id)" divided>
+														<span v-permission="'del'" class="danger-text">{{ t('common.delete') }}</span>
 													</el-dropdown-item>
 												</el-dropdown-menu>
 											</template>
@@ -192,6 +171,7 @@
 			:form-check-loading="formCheckLoading"
 			:form-check-result="formCheckResult"
 			:submit-loading="submitLoading"
+			:has-configured-credential="hasCurrentModelCredential"
 			:query-base-url-suggestions="queryBaseUrlSuggestions"
 			@source-change="handleSourceChange"
 			@type-change="handleTypeChange"
@@ -199,6 +179,8 @@
 			@add-custom-header="addCustomHeader"
 			@remove-custom-header="removeCustomHeader"
 			@check="checkModelFormConnection"
+			@update-credentials="updateCurrentModelCredentials"
+			@delete-credentials="deleteCurrentModelCredentials"
 			@submit="submitModelForm"
 		/>
 	</div>
@@ -241,6 +223,7 @@ const showModelDialog = ref(false)
 const showDetailDialog = ref(false)
 const currentModelId = ref<string | null>(null)
 const currentDetailModel = ref<ModelDetail | null>(null)
+const hasCurrentModelCredential = ref(false)
 const modelFormRef = ref()
 const customHeaderList = ref<Array<{ key: string; value: string }>>([{ key: '', value: '' }])
 const localModelOptions = ref<OllamaModel[]>([])
@@ -297,6 +280,9 @@ const availableLocalModels = computed(() =>
 	props.localOllamaModels.length ? props.localOllamaModels : localModelOptions.value
 )
 
+const hasConfiguredCredential = (credential?: Record<string, { configured?: boolean }>) =>
+	Object.values(credential || {}).some((item) => item?.configured === true)
+
 const modelForm = reactive<ModelCreateForm>({
 	source: 'local',
 	type: 'KnowledgeQA',
@@ -519,7 +505,8 @@ async function openEditModel(id: string) {
 	currentModelId.value = id
 	const res = await aiModel.postModelInfo({ id })
 	if (res.isSuccess) {
-		const data = res.result
+		const data = res.result as ModelDetail
+		hasCurrentModelCredential.value = hasConfiguredCredential(data.credential)
 		const currentHeaders = ((data as any)?.parameters?.custom_headers || {}) as Record<
 			string,
 			string
@@ -556,6 +543,7 @@ async function openEditModel(id: string) {
 
 function resetModelForm() {
 	currentModelId.value = null
+	hasCurrentModelCredential.value = false
 	customHeaderList.value = [{ key: '', value: '' }]
 	resetFormCheckResult()
 	Object.assign(modelForm, {
@@ -782,10 +770,12 @@ async function submitModelForm() {
 				}
 			}
 			if (currentModelId.value) {
-				await aiModel.postModelUpdate({ id: currentModelId.value, ...params } as any)
+				const res = await aiModel.postModelUpdate({ id: currentModelId.value, ...params } as any)
+				if (!res?.isSuccess) return
 				ElMessage.success(t('pages.model.messages.updateSuccess'))
 			} else {
-				await aiModel.postModelCreate(params as any)
+				const res = await aiModel.postModelCreate(params as any)
+				if (!res?.isSuccess) return
 				ElMessage.success(t('pages.model.messages.createSuccess'))
 			}
 			showModelDialog.value = false
@@ -800,7 +790,8 @@ async function submitModelForm() {
 
 async function deleteModelConfirm(id: string) {
 	ElMessageBox.confirm(t('pages.model.messages.deleteConfirm')).then(async () => {
-		await aiModel.postModelOpenApiDelete({ id })
+		const res = await aiModel.postModelOpenApiDelete({ id })
+		if (!res?.isSuccess) return
 		ElMessage.success(t('pages.model.messages.deleteSuccess'))
 		getAllModelList()
 	})
@@ -818,7 +809,9 @@ async function updateModelCredentials(id: string) {
 				inputValidator: (value) => !!value?.trim() || t('pages.model.messages.apiKeyRequired')
 			}
 		)
-		await aiModel.postModelCredentials({ id, api_key: value.trim() })
+		const res = await aiModel.postModelCredentials({ id, api_key: value.trim() })
+		if (!res?.isSuccess) return
+		hasCurrentModelCredential.value = true
 		ElMessage.success(t('pages.model.messages.credentialUpdateSuccess'))
 		getAllModelList()
 	} catch (error) {
@@ -828,6 +821,11 @@ async function updateModelCredentials(id: string) {
 	}
 }
 
+function updateCurrentModelCredentials() {
+	if (!currentModelId.value) return
+	void updateModelCredentials(currentModelId.value)
+}
+
 async function deleteModelCredentials(id: string) {
 	try {
 		await ElMessageBox.confirm(
@@ -837,7 +835,9 @@ async function deleteModelCredentials(id: string) {
 				type: 'warning'
 			}
 		)
-		await aiModel.postModelDeleteCredentials({ id })
+		const res = await aiModel.postModelDeleteCredentials({ id })
+		if (!res?.isSuccess) return
+		hasCurrentModelCredential.value = false
 		ElMessage.success(t('pages.model.messages.credentialDeleteSuccess'))
 		getAllModelList()
 	} catch (error) {
@@ -847,6 +847,11 @@ async function deleteModelCredentials(id: string) {
 	}
 }
 
+function deleteCurrentModelCredentials() {
+	if (!currentModelId.value) return
+	void deleteModelCredentials(currentModelId.value)
+}
+
 watch(
 	() => modelForm.type,
 	() => {

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

@@ -71,6 +71,7 @@ export interface ModelCreateForm {
 export interface ModelDetail {
 	creationTime: string
 	creatorUserId: string
+	credential?: Record<string, { configured?: boolean }>
 	description: string
 	id: string
 	isDeleted: boolean

+ 58 - 24
apps/web/src/views/prompt/index.vue

@@ -43,7 +43,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>
@@ -53,16 +53,24 @@
 			</div>
 
 			<div v-loading="loading" class="card-grid">
-				<el-empty v-if="!visibleList.length && !loading" :description="t('pages.prompt.noPrompt')" class="empty" />
+				<el-empty
+					v-if="!visibleList.length && !loading"
+					:description="t('pages.prompt.noPrompt')"
+					class="empty"
+				/>
 				<div v-for="item in visibleList" :key="item.id" class="resource-card">
 					<div class="resource-card__top">
 						<div>
 							<div class="resource-card__title flex items-center gap-4px">
-								<el-tag v-if="item.is_builtin" type="success" effect="light">{{ t('pages.prompt.builtIn') }}</el-tag
+								<el-tag v-if="item.is_builtin" type="success" effect="light">{{
+									t('pages.prompt.builtIn')
+								}}</el-tag
 								>{{ item.name }}
 							</div>
 
-							<div class="resource-card__desc">{{ item.description || t('pages.prompt.noDescription') }}</div>
+							<div class="resource-card__desc">
+								{{ item.description || t('pages.prompt.noDescription') }}
+							</div>
 						</div>
 						<el-dropdown :hide-on-click="false">
 							<span class="cursor-pointer">
@@ -73,13 +81,23 @@
 							<template #dropdown>
 								<el-dropdown-menu>
 									<el-dropdown-item>
-										<el-button link type="primary" @click="openDetail(item)">{{ t('pages.prompt.detail') }}</el-button>
+										<el-button link type="primary" @click="openDetail(item)">{{
+											t('pages.prompt.detail')
+										}}</el-button>
 									</el-dropdown-item>
 									<el-dropdown-item>
-										<el-button link type="primary" @click="openEdit(item)">{{ t('common.edit') }}</el-button>
+										<el-button v-permission="'edit'" link type="primary" @click="openEdit(item)">{{
+											t('common.edit')
+										}}</el-button>
 									</el-dropdown-item>
-									<el-dropdown-item>
-										<el-button link type="danger" @click="removeItem(item.id)">{{ t('common.delete') }}</el-button>
+									<el-dropdown-item divider>
+										<el-button
+											v-permission="'del'"
+											link
+											type="danger"
+											@click="removeItem(item.id)"
+											>{{ t('common.delete') }}</el-button
+										>
 									</el-dropdown-item>
 								</el-dropdown-menu>
 							</template>
@@ -150,15 +168,21 @@
 			<template #footer>
 				<div class="drawer-footer">
 					<el-button @click="drawerVisible = false">{{ t('common.cancel') }}</el-button>
-					<el-button type="primary" :loading="submitLoading" @click="handleSubmit">{{ t('common.save') }}</el-button>
+					<el-button type="primary" :loading="submitLoading" @click="handleSubmit">{{
+						t('common.save')
+					}}</el-button>
 				</div>
 			</template>
 		</el-drawer>
 
 		<el-drawer v-model="detailVisible" :title="t('pages.prompt.promptDetail')" width="760px">
 			<el-descriptions v-if="detailItem" :column="1" direction="vertical">
-				<el-descriptions-item :label="t('pages.prompt.name')">{{ detailItem.name }}</el-descriptions-item>
-				<el-descriptions-item :label="t('pages.prompt.type')">{{ formatType(detailItem.type) }}</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.prompt.name')">{{
+					detailItem.name
+				}}</el-descriptions-item>
+				<el-descriptions-item :label="t('pages.prompt.type')">{{
+					formatType(detailItem.type)
+				}}</el-descriptions-item>
 				<el-descriptions-item :label="t('pages.prompt.description')">{{
 					detailItem.description || '-'
 				}}</el-descriptions-item>
@@ -225,17 +249,19 @@ const rules = {
 const visibleList = computed(() => list.value)
 
 const typeMap = computed<Record<string, string>>(() => ({
-	'system': t('pages.prompt.typeMap.system'),
-	'agent_system': t('pages.prompt.typeMap.agentSystem'),
-	'rewrite': t('pages.prompt.typeMap.rewrite'),
-	'fallback': t('pages.prompt.typeMap.fallback'),
-	'context_template': t('pages.prompt.typeMap.contextTemplate'),
-	'generate_title': t('pages.prompt.typeMap.generateTitle'),
-	'generate_summary': t('pages.prompt.typeMap.generateSummary'),
-	'keyword_extraction': t('pages.prompt.typeMap.keywordExtraction')
+	system: t('pages.prompt.typeMap.system'),
+	agent_system: t('pages.prompt.typeMap.agentSystem'),
+	rewrite: t('pages.prompt.typeMap.rewrite'),
+	fallback: t('pages.prompt.typeMap.fallback'),
+	context_template: t('pages.prompt.typeMap.contextTemplate'),
+	generate_title: t('pages.prompt.typeMap.generateTitle'),
+	generate_summary: t('pages.prompt.typeMap.generateSummary'),
+	keyword_extraction: t('pages.prompt.typeMap.keywordExtraction')
 }))
 
-const typeList = computed(() => Object.keys(typeMap.value).map((type) => ({ label: typeMap.value[type], value: type })))
+const typeList = computed(() =>
+	Object.keys(typeMap.value).map((type) => ({ label: typeMap.value[type], value: type }))
+)
 
 function formatType(type?: string) {
 	return typeMap.value[type || ''] || type || '-'
@@ -333,10 +359,15 @@ async function handleSubmit() {
 				has_web_search: form.has_web_search
 			}
 			if (currentId.value) {
-				await resource.postPromptTemplateUpdate({ id: currentId.value, ...(payload as any) })
+				const res = await resource.postPromptTemplateUpdate({
+					id: currentId.value,
+					...(payload as any)
+				})
+				if (!res?.isSuccess) return
 				ElMessage.success(t('pages.prompt.updateSuccess'))
 			} else {
-				await resource.postPromptTemplateCreate(payload as any)
+				const res = await resource.postPromptTemplateCreate(payload as any)
+				if (!res?.isSuccess) return
 				ElMessage.success(t('pages.prompt.createSuccess'))
 			}
 			drawerVisible.value = false
@@ -351,8 +382,11 @@ async function handleSubmit() {
 
 async function removeItem(id: string) {
 	try {
-		await ElMessageBox.confirm(t('pages.prompt.confirmDelete'), t('pages.prompt.tip'), { type: 'warning' })
-		await resource.postPromptTemplateOpenApiDelete({ id })
+		await ElMessageBox.confirm(t('pages.prompt.confirmDelete'), t('pages.prompt.tip'), {
+			type: 'warning'
+		})
+		const res = await resource.postPromptTemplateOpenApiDelete({ id })
+		if (!res?.isSuccess) return
 		ElMessage.success(t('pages.prompt.deleteSuccess'))
 		loadList(pagination.pageIndex)
 	} catch {

+ 34 - 8
apps/web/src/views/storage/index.vue

@@ -25,7 +25,9 @@
 						<div class="card-head__top">
 							<div class="title-block">
 								<div class="title">{{ formatProviderLabel(item.name) }}</div>
-								<div class="subtitle">{{ item.description || t('pages.storagePage.engineDescription') }}</div>
+								<div class="subtitle">
+									{{ item.description || t('pages.storagePage.engineDescription') }}
+								</div>
 							</div>
 							<div class="actions">
 								<el-dropdown>
@@ -40,9 +42,17 @@
 												:disabled="currentDefaultProvider === item.name"
 												@click="updateDefaultProvider(item.name)"
 											>
-												{{ currentDefaultProvider === item.name ? t('pages.storagePage.currentDefault') : t('pages.storagePage.setDefault') }}
+												{{
+													currentDefaultProvider === item.name
+														? t('pages.storagePage.currentDefault')
+														: t('pages.storagePage.setDefault')
+												}}
+											</el-dropdown-item>
+											<el-dropdown-item @click="openEditProvider(item.name)">
+												<el-button v-permission="'edit'" link type="primary">{{
+													t('common.edit')
+												}}</el-button>
 											</el-dropdown-item>
-											<el-dropdown-item v-permission="'edit'" @click="openEditProvider(item.name)">{{ t('common.edit') }}</el-dropdown-item>
 										</el-dropdown-menu>
 									</template>
 								</el-dropdown>
@@ -59,20 +69,34 @@
 							{{ t('pages.storagePage.defaultLabel') }}
 						</el-tag>
 						<el-tag :type="item.allowed ? 'success' : 'info'" effect="light">
-							{{ item.allowed ? t('pages.storagePage.allowed') : t('pages.storagePage.notAllowed') }}
+							{{
+								item.allowed ? t('pages.storagePage.allowed') : t('pages.storagePage.notAllowed')
+							}}
 						</el-tag>
 						<el-tag :type="item.available ? 'primary' : 'warning'" effect="light">
-							{{ item.available ? t('pages.storagePage.available') : t('pages.storagePage.notAvailable') }}
+							{{
+								item.available
+									? t('pages.storagePage.available')
+									: t('pages.storagePage.notAvailable')
+							}}
 						</el-tag>
 					</div>
 				</div>
-				<el-empty v-if="!engines.length" class="empty" :description="t('pages.storagePage.noEngine')" />
+				<el-empty
+					v-if="!engines.length"
+					class="empty"
+					:description="t('pages.storagePage.noEngine')"
+				/>
 			</div>
 		</el-card>
 
 		<el-drawer
 			v-model="drawerVisible"
-			:title="selectedProvider ? `${formatProviderLabel(selectedProvider)} ${t('pages.storagePage.editStorageEngine')}` : t('pages.storagePage.editStorageEngine')"
+			:title="
+				selectedProvider
+					? `${formatProviderLabel(selectedProvider)} ${t('pages.storagePage.editStorageEngine')}`
+					: t('pages.storagePage.editStorageEngine')
+			"
 			direction="rtl"
 			size="760px"
 		>
@@ -265,7 +289,9 @@
 					>
 						{{ t('pages.storagePage.testConnection') }}
 					</el-button>
-					<el-button v-permission="'edit'" type="primary" :loading="saving" @click="saveConfig">{{ t('pages.storagePage.saveConfig') }}</el-button>
+					<el-button v-permission="'edit'" type="primary" :loading="saving" @click="saveConfig">{{
+						t('pages.storagePage.saveConfig')
+					}}</el-button>
 				</div>
 			</template>
 		</el-drawer>

+ 5 - 3
apps/web/src/views/vector/index.vue

@@ -36,7 +36,7 @@
 					</el-form-item>
 				</el-form>
 				<div class="toolbar-actions">
-					<el-button type="primary" @click="openCreate">
+					<el-button v-permission="'add'" type="primary" @click="openCreate">
 						<el-icon><Plus /></el-icon>
 						{{ t('pages.vectorStore.create') }}
 					</el-button>
@@ -63,13 +63,15 @@
 												{{ t('pages.vectorStore.testConnection') }}
 											</el-dropdown-item>
 											<el-dropdown-item @click="openEdit(item)">
-												{{ t('common.edit') }}
+												<span v-permission="'edit'">{{ t('common.edit') }}</span>
 											</el-dropdown-item>
 											<el-dropdown-item @click="openDetail(item)">
 												{{ t('common.details') }}
 											</el-dropdown-item>
 											<el-dropdown-item divided @click="handleDeleteConfirm(item)">
-												{{ t('common.delete') }}
+												<el-button link type="danger" v-permission="'del'">{{
+													t('common.delete')
+												}}</el-button>
 											</el-dropdown-item>
 										</el-dropdown-menu>
 									</template>

+ 42 - 19
apps/web/src/views/web-search/index.vue

@@ -77,21 +77,11 @@
 									</span>
 									<template #dropdown>
 										<el-dropdown-menu>
-											<el-dropdown-item v-permission="'edit'" @click="openEditById(row.id)">{{
-												t('common.edit')
-											}}</el-dropdown-item>
-											<el-dropdown-item
-												v-permission="'edit'"
-												@click="updateCredentials(row.id)"
-												divided
-											>
-												{{ t('pages.webSearch.updateCredential') }}
+											<el-dropdown-item @click="openEditById(row.id)">
+												<span v-permission="'edit'">{{ t('common.edit') }}</span>
 											</el-dropdown-item>
-											<el-dropdown-item v-permission="'edit'" @click="deleteCredentials(row.id)">
-												<span class="danger-text">{{ t('pages.webSearch.deleteCredential') }}</span>
-											</el-dropdown-item>
-											<el-dropdown-item v-permission="'del'" @click="removeItem(row.id)" divided>
-												<span class="danger-text">{{ t('common.delete') }}</span>
+											<el-dropdown-item @click="removeItem(row.id)" divided>
+												<span v-permission="'del'" class="danger-text">{{ t('common.delete') }}</span>
 											</el-dropdown-item>
 										</el-dropdown-menu>
 									</template>
@@ -160,6 +150,21 @@
 				<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>
+				<el-form-item v-if="currentId">
+					<div class="credential-actions">
+						<el-button type="primary" plain @click="updateCredentials(currentId)">
+							{{ t('pages.webSearch.updateCredential') }}
+						</el-button>
+						<el-button
+							v-if="hasCurrentCredential"
+							type="danger"
+							plain
+							@click="deleteCredentials(currentId)"
+						>
+							{{ t('pages.webSearch.deleteCredential') }}
+						</el-button>
+					</div>
+				</el-form-item>
 				<div class="grid-2">
 					<el-form-item :label="t('pages.webSearch.proxyUrl')" prop="parameters.proxy_url">
 						<el-input v-model="form.parameters.proxy_url" />
@@ -217,6 +222,7 @@ const submitLoading = ref(false)
 const checkLoading = ref(false)
 const drawerVisible = ref(false)
 const currentId = ref('')
+const hasCurrentCredential = ref(false)
 const checkMessage = ref('')
 const checkSuccess = ref(false)
 const formRef = ref()
@@ -230,6 +236,9 @@ const pagination = reactive({
 const list = ref<any[]>([])
 const engineOptions = ref<EngineItem[]>([])
 
+const hasConfiguredCredential = (credential?: Record<string, { configured?: boolean }>) =>
+	Object.values(credential || {}).some((item) => item?.configured === true)
+
 const form = reactive({
 	provider: '',
 	name: '',
@@ -251,6 +260,7 @@ const rules = {
 
 function resetForm() {
 	currentId.value = ''
+	hasCurrentCredential.value = false
 	checkMessage.value = ''
 	checkSuccess.value = false
 	form.provider = ''
@@ -331,6 +341,7 @@ async function openEditById(id: string) {
 	form.description = res.result.description || ''
 	form.is_default = !!res.result.is_default
 	form.parameters.api_key = res.result.parameters?.api_key || ''
+	hasCurrentCredential.value = hasConfiguredCredential((res.result as any).credential)
 	drawerVisible.value = true
 }
 
@@ -393,10 +404,12 @@ async function handleSubmit() {
 				}
 			}
 			if (currentId.value) {
-				await resource.postWebSearchUpdate({ id: currentId.value, ...(payload as any) })
+				const res = await resource.postWebSearchUpdate({ id: currentId.value, ...(payload as any) })
+				if (!res?.isSuccess) return
 				ElMessage.success(t('pages.webSearch.updateSuccess'))
 			} else {
-				await resource.postWebSearchCreate(payload as any)
+				const res = await resource.postWebSearchCreate(payload as any)
+				if (!res?.isSuccess) return
 				ElMessage.success(t('pages.webSearch.createSuccess'))
 			}
 			drawerVisible.value = false
@@ -414,7 +427,8 @@ async function removeItem(id: string) {
 		await ElMessageBox.confirm(t('pages.webSearch.confirmDelete'), t('pages.webSearch.tip'), {
 			type: 'warning'
 		})
-		await resource.postWebSearchOpenApiDelete({ id })
+		const res = await resource.postWebSearchOpenApiDelete({ id })
+		if (!res?.isSuccess) return
 		ElMessage.success(t('pages.webSearch.deleteSuccess'))
 		loadList(pagination.pageIndex)
 	} catch {
@@ -434,7 +448,9 @@ async function updateCredentials(id: string) {
 				inputValidator: (value) => !!value?.trim() || t('pages.webSearch.pleaseInputApiKey')
 			}
 		)
-		await resource.postWebSearchCredentials({ id, api_key: value.trim() })
+		const res = await resource.postWebSearchCredentials({ id, api_key: value.trim() })
+		if (!res?.isSuccess) return
+		hasCurrentCredential.value = true
 		ElMessage.success(t('pages.webSearch.credentialUpdateSuccess'))
 		loadList(pagination.pageIndex)
 	} catch (error) {
@@ -453,7 +469,9 @@ async function deleteCredentials(id: string) {
 				type: 'warning'
 			}
 		)
-		await resource.postWebSearchDeleteCredentials({ id })
+		const res = await resource.postWebSearchDeleteCredentials({ id })
+		if (!res?.isSuccess) return
+		hasCurrentCredential.value = false
 		ElMessage.success(t('pages.webSearch.credentialDeleteSuccess'))
 		loadList(pagination.pageIndex)
 	} catch (error) {
@@ -742,6 +760,11 @@ onMounted(async () => {
 	gap: 10px;
 }
 
+.credential-actions {
+	display: flex;
+	gap: 8px;
+}
+
 @media (max-width: 768px) {
 	.search-input {
 		width: 100%;

+ 33 - 5
packages/api-service/schema/system.openapi.json

@@ -1047,9 +1047,9 @@
 				"security": []
 			}
 		},
-		"/api/menubutton/authorised": {
+		"/api/menubutton/authorised_list": {
 			"post": {
-				"summary": "根据菜单编号,获取用户已授权的菜单按钮编号列表",
+				"summary": "根据菜单编号,获取用户已授权的菜单按钮列表",
 				"deprecated": false,
 				"description": "状态 0: 已创建  1: 运行中  2: 成功  3: 失败  4: 挂起",
 				"tags": ["system"],
@@ -1058,7 +1058,7 @@
 						"name": "Authorization",
 						"in": "header",
 						"description": "",
-						"example": "bpm_backend_1523343531161161728",
+						"example": "bpm_backend_1524236674609975296",
 						"schema": {
 							"type": "string"
 						}
@@ -1100,7 +1100,19 @@
 										"result": {
 											"type": "array",
 											"items": {
-												"type": "string"
+												"type": "object",
+												"properties": {
+													"code": {
+														"type": "string"
+													},
+													"id": {
+														"type": "string"
+													},
+													"name": {
+														"type": "string"
+													}
+												},
+												"required": ["code", "id", "name"]
 											}
 										},
 										"isAuthorized": {
@@ -1112,7 +1124,23 @@
 								"example": {
 									"isSuccess": true,
 									"code": 1,
-									"result": ["add", "edit", "del"],
+									"result": [
+										{
+											"code": "edit",
+											"id": "622402cc-cb16-4aa2-9a9e-ee5082bd2265",
+											"name": "编辑"
+										},
+										{
+											"code": "del",
+											"id": "74e07200-c653-42d0-aa82-fcb1e28c48a0",
+											"name": "删除"
+										},
+										{
+											"code": "add",
+											"id": "993fcebc-547f-4323-9dd2-edde22316d68",
+											"name": "添加"
+										}
+									],
 									"isAuthorized": true
 								}
 							}

+ 15 - 13
packages/api-service/servers/system/api/system.ts

@@ -158,22 +158,24 @@ export async function postMenuLeftMenuList(
   })
 }
 
-/** 根据菜单编号,获取用户已授权的菜单按钮编号列表 状态 0: 已创建  1: 运行中  2: 成功  3: 失败  4: 挂起 POST /api/menubutton/authorised */
-export async function postMenubuttonAuthorised(
+/** 根据菜单编号,获取用户已授权的菜单按钮列表 状态 0: 已创建  1: 运行中  2: 成功  3: 失败  4: 挂起 POST /api/menubutton/authorised_list */
+export async function postMenubuttonAuthorisedList(
   body: {
     menuCode: string
   },
   options?: { [key: string]: any }
 ) {
-  return request<{ isSuccess: boolean; code: number; result: string[]; isAuthorized: boolean }>(
-    '/api/menubutton/authorised',
-    {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json'
-      },
-      data: body,
-      ...(options || {})
-    }
-  )
+  return request<{
+    isSuccess: boolean
+    code: number
+    result: { code: string; id: string; name: string }[]
+    isAuthorized: boolean
+  }>('/api/menubutton/authorised_list', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json'
+    },
+    data: body,
+    ...(options || {})
+  })
 }