feat(extensions): add ZIP installation and unify action dialogs

This commit is contained in:
2026-09-06 00:52:59 +08:00
parent ba66b182af
commit 99a92e9eb1
29 changed files with 554 additions and 50 deletions
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askPrompt } = useActionDialog()
import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue'
@@ -119,20 +122,28 @@ function runCommand(command: ToolbarCommand) {
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
}
function applyLink() {
async function applyLink() {
if (!crepe) return
// TODO(editor): 用受控 Element Plus 对话框替换 prompt,补充 URL 校验和键盘焦点管理。
const href = window.prompt(t('请输入链接地址', 'Enter link address'), 'https://')?.trim()
if (!href) return
crepe.editor.action((ctx) => {
const editor = crepe
const snapshot = editor.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
return { doc: view.state.doc, selection: view.state.selection }
})
const href = (await askPrompt(t('请输入链接地址', 'Enter link address'), 'https://'))?.trim()
if (!href || crepe !== editor) return
const label = snapshot.selection.empty ? await askPrompt(t('请输入链接文字', 'Enter link text'), href) : ''
if (label === null || crepe !== editor) return
editor.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
if (!view.state.doc.eq(snapshot.doc)) return
view.dispatch(view.state.tr.setSelection(snapshot.selection))
const commands = ctx.get(commandsCtx)
if (view.state.selection.empty) {
const label = window.prompt(t('请输入链接文字', 'Enter link text'), href)?.trim() || href
const text = label.trim() || href
const from = view.state.selection.from
const transaction = view.state.tr.insertText(label, from)
transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length))
const transaction = view.state.tr.insertText(text, from)
transaction.setSelection(TextSelection.create(transaction.doc, from, from + text.length))
view.dispatch(transaction)
}
return commands.call(toggleLinkCommand.key, { href })
@@ -278,6 +289,7 @@ defineExpose({ getEditor: () => crepe?.editor })
<template>
<DiagramInteractions class="visual-editor">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span>
@@ -29,7 +29,6 @@ async function render(items: McpServer[] = []) {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('confirm', vi.fn(() => true))
})
describe('McpServersView', () => {
@@ -78,7 +77,9 @@ describe('McpServersView', () => {
vi.mocked(service.deleteMcpServer).mockResolvedValue({ status: 'completed' })
await wrapper.findAll('button').find(button => button.text().includes('删除'))!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalled()
expect(service.deleteMcpServer).not.toHaveBeenCalled()
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
expect(service.deleteMcpServer).toHaveBeenCalledWith('server-1')
})
@@ -89,7 +90,10 @@ describe('McpServersView', () => {
await wrapper.get('input[placeholder="network.request, notes.read"]').setValue('notes.read')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效'))
expect(wrapper.get('.action-dialog').text()).toContain('旧测试与授权会失效')
expect(service.updateMcpServer).not.toHaveBeenCalled()
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
expect(service.updateMcpServer).toHaveBeenCalled()
})
@@ -125,6 +129,8 @@ describe('McpServersView', () => {
expect(wrapper.get('.modal-card [role="alert"]').text()).toContain('服务器配置已保存,但密钥保存失败')
await wrapper.get('form').trigger('submit')
await flushPromises()
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledTimes(1)
expect(service.updateMcpServer).toHaveBeenCalledWith('new-server', expect.objectContaining({ version: 1 }))
expect(service.putMcpServerSecret).toHaveBeenCalledTimes(2)
@@ -144,6 +150,8 @@ describe('McpServersView', () => {
vi.mocked(service.updateMcpServer).mockResolvedValue(server)
await wrapper.get('form').trigger('submit')
await flushPromises()
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
expect(service.updateMcpServer).toHaveBeenCalledWith('server-1', expect.objectContaining({ version: 2, headers: {}, args: [] }))
expect(service.putMcpServerSecret).not.toHaveBeenCalled()
})
+7 -3
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
@@ -143,7 +146,7 @@ async function save() {
error.value = ''
const input = payload()
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error(t('请填写服务器名称和连接地址', 'Enter a server name and connection address'))
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?'))) return
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !(await askConfirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?')))) return
busy.value = 'save'
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
// Commit the returned ID/version before saving secrets so a partial failure can
@@ -192,7 +195,7 @@ function executionChanged(server: McpServer, input: McpServerInput) {
async function approve(server: McpServer): Promise<McpServer | null> {
if (server.trusted) return server
const localWarning = server.transport === 'stdio' ? t('\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。', '\n\nLocal processes have no system-level sandbox. Run trusted servers only.') : t('\n\n连接可能向该地址发送配置的 Header。', '\n\nThe connection may send configured headers to this address.')
if (!confirm(`${t('请确认 MCP 连接:', 'Confirm MCP connection:')}\n\n${server.command_summary}${localWarning}\n\n${t('是否继续?', 'Continue?')}`)) return null
if (!(await askConfirm(`${t('请确认 MCP 连接:', 'Confirm MCP connection:')}\n\n${server.command_summary}${localWarning}\n\n${t('是否继续?', 'Continue?')}`))) return null
return service.trustMcpServer(server)
}
@@ -206,7 +209,7 @@ async function act(server: McpServer, action: string, operation: (server: McpSer
}
async function remove(server: McpServer) {
if (!confirm(t(`删除“${server.name}”及其加密凭据?`, `Delete “${server.name}” and its encrypted credentials?`))) return
if (!(await askConfirm(t(`删除“${server.name}”及其加密凭据?`, `Delete “${server.name}” and its encrypted credentials?`)))) return
try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() }
catch (cause) { error.value = message(cause, t('删除失败', 'Delete failed')) } finally { busy.value = '' }
}
@@ -226,6 +229,7 @@ onMounted(load)
<template>
<section class="feature-page mcp-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('MCP 服务器', 'MCP Servers') }}</h1><p>{{ t('管理独立 MCP Server 的连接、凭据与工具生命周期。', 'Manage standalone MCP server connections, credentials, and tool lifecycles.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> {{ t('刷新', 'Refresh') }}</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> {{ t('新增服务器', 'Add server') }}</button></div></header>
<div class="notice-banner">{{ t('stdio 本机进程仅在开发环境开放;Streamable HTTP 为首选远程传输,SSE 仅用于兼容旧服务器。uvx 隔离依赖但不是安全沙箱。', 'Local stdio processes are available only in development. Streamable HTTP is the preferred remote transport; SSE supports legacy servers. uvx isolates dependencies but is not a security sandbox.') }}</div>
<div v-if="error" class="error-banner">{{ error }}</div>
+6 -2
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
@@ -48,7 +51,7 @@ async function refresh() {
if (!stopped) timer = setTimeout(refresh, 2000)
}
async function choose(job: MediaJob) {
if (dirty.value && !window.confirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?'))) return
if (dirty.value && !(await askConfirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?')))) return
selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = []
}
async function action(work: () => Promise<void>) {
@@ -77,7 +80,7 @@ async function purge() {
if (!selected.value) return
await action(async () => {
const impact = await mediaService.impact(selected.value!.attachment_id)
if (!window.confirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`)) return
if (!(await askConfirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`))) return
await mediaService.purge(selected.value!.attachment_id)
selected.value = await mediaService.get(selected.value!.job_id)
dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed')
@@ -110,6 +113,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template>
<section class="media-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
<form class="panel upload" @submit.prevent="submit">
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { Key, Refresh } from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/common/AppIcon.vue'
@@ -116,11 +119,14 @@ async function saveSecret(field: PluginSettingField) {
} catch (reason) { feedback(message(reason, t('密钥保存失败', 'Failed to save secret'))) } finally { busy.value = '' }
}
async function deleteSecret(field: PluginSettingField) {
if (!confirm(t('删除已保存的', 'Delete saved ') + field.label + '')) return
const pluginId = props.plugin.plugin_id
if (!(await askConfirm(t('删除已保存的', 'Delete saved ') + field.label + ''))) return
if (pluginId !== props.plugin.plugin_id) return
busy.value = 'secret:' + field.key
feedback()
try {
const state = await pluginService.deletePluginSecret(props.plugin.plugin_id, field.key)
const state = await pluginService.deletePluginSecret(pluginId, field.key)
if (pluginId !== props.plugin.plugin_id) return
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = ''
notice.value = field.label + t('已删除。', ' deleted.')
@@ -130,6 +136,7 @@ async function deleteSecret(field: PluginSettingField) {
<template>
<section class="mcp-panel">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<nav class="mcp-tabs" :aria-label="t('MCP Plugin 配置', 'MCP and Plugin settings')">
<button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button>
</nav>
@@ -54,13 +54,17 @@ it.each(['save', 'delete'] as const)('ignores old secret %s responses after swit
let finish!: () => void
vi.mocked(service.putPluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: true }) }))
if (action === 'delete') {
vi.stubGlobal('confirm', vi.fn(() => true))
vi.mocked(service.deletePluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: false }) }))
}
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('old-fixture-value')
await wrapper.get(action === 'save' ? '.secret-row button' : '.secret-row .danger').trigger('click')
if (action === 'delete') {
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
}
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema(false))
await wrapper.setProps({ pluginId: 'other' })
await flushPromises()
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import type { PluginSettingsSchema, PluginSettingField } from '@/contracts'
import {
@@ -106,9 +109,10 @@ async function saveSecret(key: string) {
async function clearSecret(key: string) {
if (!schema.value || isSaving.value) return
if (!confirm(`确认删除 " ${key} " 的配置?`)) return
const version = loadVersion
const pluginId = props.pluginId
if (!(await askConfirm(`确认删除 " ${key} " 的配置?`))) return
if (version !== loadVersion || pluginId !== props.pluginId) return
isSaving.value = true
saveError.value = ''
try {
@@ -145,6 +149,7 @@ watch(() => props.pluginId, load)
<template>
<div class="plugin-settings-panel">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div v-if="isLoading" class="loading">加载设置中</div>
<template v-else-if="schema && schema.fields.length > 0">
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { Connection } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.vue'
@@ -39,13 +42,13 @@ async function toggle(id: string, enabled: boolean) {
}
async function grant(id: string, permissions: string[]) {
if (!confirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`)) return
if (!(await askConfirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`))) return
try { await pluginStore.grantPermissions(id, permissions) }
catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') }
}
async function uninstall(id: string, name: string) {
if (!confirm(t(`卸载「${name}」将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`))) return
if (!(await askConfirm(t(`卸载「${name}」将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`)))) return
try { await pluginStore.uninstallPlugin(id) }
catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
}
@@ -61,6 +64,7 @@ const hasCommandContribution = computed(() =>
<template>
<section class="feature-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<ExtensionInstallDialog v-if="showInstall" kind="Plugin" :install="pluginStore.installPlugin" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
<header class="feature-header">
<div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div>
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue'
@@ -62,7 +65,7 @@ async function providerSaved(provider: ProviderConfig) {
if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
}
async function removeProvider(provider: ProviderConfig) { if (!confirm(`${t('确定删除 Provider', 'Delete Provider')}${provider.name}”?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : t('删除失败', 'Delete failed') } }
async function removeProvider(provider: ProviderConfig) { if (!(await askConfirm(`${t('确定删除 Provider', 'Delete Provider')}${provider.name}”?`))) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : t('删除失败', 'Delete failed') } }
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = t('测试中…', 'Testing…'); const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `${t('连接成功', 'Connection succeeded')}${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `${t('连接失败:', 'Connection failed: ')}${result.error}` }
async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
@@ -74,6 +77,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<template>
<section class="feature-page settings-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('设置', 'Settings') }}</h1><p>{{ t('管理应用偏好、模型、索引、权限和本地 AI Core。', 'Manage application preferences, models, indexing, permissions, and the local AI Core.') }}</p></div></header>
<nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav>
+5 -1
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { Lightning } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.vue'
@@ -16,13 +19,14 @@ async function toggle(skillId: string, enabled: boolean) {
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
}
async function uninstall(skillId: string, name: string) {
if (!confirm(`${t('确定卸载 Skill', 'Uninstall Skill')}${name}”?`)) return
if (!(await askConfirm(`${t('确定卸载 Skill', 'Uninstall Skill')}${name}”?`))) return
try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
}
</script>
<template>
<section class="feature-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header>
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
+5 -1
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import AppDialog from '@/components/common/AppDialog.vue'
import { onMounted, reactive, ref } from 'vue'
import type { TaskItem, TaskStatus } from '@/contracts'
@@ -30,13 +33,14 @@ async function setStatus(task: TaskItem, status: TaskStatus) {
}
async function remove(task: TaskItem) {
if (!confirm(`${t('确定删除任务', 'Delete task')}${task.title}”?`)) return
if (!(await askConfirm(`${t('确定删除任务', 'Delete task')}${task.title}”?`))) return
try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('任务删除失败', 'Failed to delete task') }
}
</script>
<template>
<section class="feature-page tasks-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('任务', 'Tasks') }}</h1><p>{{ t('管理用户、笔记和 Agent 产生的行动项。', 'Manage action items created by users, notes, and agents.') }}</p></div><button class="button-primary" @click="resetForm(); showForm = true"> {{ t('新建任务', 'New task') }}</button></header>
<div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div>
<div v-if="taskStore.filteredTasks.length" class="task-list">
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm, askPrompt } = useActionDialog()
import { computed, nextTick, ref, watch } from 'vue'
import { noteOutline } from './outline'
import { useRouter } from 'vue-router'
@@ -168,7 +171,7 @@ function closeContextMenu() { contextTarget.value = null }
async function renameTarget() {
const node = contextTarget.value
if (!node) return
const newName = window.prompt(t('新名称', 'New name'), node.name)?.trim()
const newName = (await askPrompt(t('新名称', 'New name'), node.name))?.trim()
if (newName && newName !== node.name) {
const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName
const oldPath = node.path
@@ -190,7 +193,7 @@ async function renameTarget() {
async function deleteTarget() {
const node = contextTarget.value
if (!node) return
if (!window.confirm(`${t('确定要删除', 'Delete')}${node.name}”?`)) return closeContextMenu()
if (!(await askConfirm(`${t('确定要删除', 'Delete')}${node.name}”?`))) return closeContextMenu()
await workspaceService.deleteFile(node.path)
const activeWasRemoved = workspaceStore.closePath(node.path)
workspaceStore.removeFromTree(node.path)
@@ -213,6 +216,7 @@ function containingFolder(path: string): string {
<template>
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs">
<button id="workspace-files-tab" role="tab" aria-controls="workspace-files-panel" :aria-selected="activeTab === 'files'" :tabindex="activeTab === 'files' ? 0 : -1" @click="switchTab('files')">{{ t('文件', 'Files') }}</button>
<button id="workspace-outline-tab" role="tab" aria-controls="workspace-outline-panel" :aria-selected="activeTab === 'outline'" :tabindex="activeTab === 'outline' ? 0 : -1" @click="switchTab('outline')">{{ t('大纲', 'Outline') }}</button>