feat(extensions): add ZIP installation and unify action dialogs
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import ActionDialog from './ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
afterEach(() => wrapper?.unmount())
|
||||
function setup() {
|
||||
let api!: ReturnType<typeof useActionDialog>
|
||||
wrapper = mount(defineComponent({
|
||||
components: { ActionDialog },
|
||||
setup() { api = useActionDialog(); return api },
|
||||
template: '<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />',
|
||||
}), { attachTo: document.body })
|
||||
return api
|
||||
}
|
||||
it('requires explicit confirmation and treats Escape as cancellation', async () => {
|
||||
const api = setup()
|
||||
const action = vi.fn()
|
||||
const result = api.askConfirm('删除所有配置?').then(ok => { if (ok) action() })
|
||||
await flushPromises()
|
||||
expect(document.activeElement?.textContent).toBe('取消')
|
||||
await wrapper.get('dialog').trigger('cancel')
|
||||
await result
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
const confirmed = api.askConfirm('继续?')
|
||||
await flushPromises()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(await confirmed).toBe(true)
|
||||
})
|
||||
it('preserves the default input and distinguishes empty submission from cancel', async () => {
|
||||
const api = setup()
|
||||
const input = api.askPrompt('新名称', '旧名称')
|
||||
await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('旧名称')
|
||||
await wrapper.get('input').setValue('')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(await input).toBe('')
|
||||
const cancelled = api.askPrompt('名称')
|
||||
await flushPromises()
|
||||
await wrapper.get('button[type="button"]').trigger('click')
|
||||
expect(await cancelled).toBeNull()
|
||||
})
|
||||
it('cancels duplicate requests and pending operations when their view unmounts', async () => {
|
||||
const api = setup()
|
||||
const first = api.askConfirm('继续?')
|
||||
expect(await api.askConfirm('重复')).toBe(false)
|
||||
wrapper.unmount()
|
||||
expect(await first).toBe(false)
|
||||
expect(await api.askPrompt('已离开')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import type { ActionDialogRequest } from '@/composables/useActionDialog'
|
||||
import { t } from '@/i18n'
|
||||
const props = defineProps<ActionDialogRequest>()
|
||||
const emit = defineEmits<{ resolve: [value: string | null] }>()
|
||||
const value = ref(props.initialValue)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :label="mode === 'confirm' ? t('确认操作', 'Confirm action') : message" @close="emit('resolve', null)">
|
||||
<form class="modal action-dialog" @submit.prevent="emit('resolve', mode === 'prompt' ? value : '')">
|
||||
<span class="badge info">{{ mode === 'confirm' ? t('操作确认', 'Confirmation') : t('填写信息', 'Enter information') }}</span>
|
||||
<h2>{{ mode === 'confirm' ? t('确认操作', 'Confirm action') : t('请输入', 'Enter a value') }}</h2>
|
||||
<label v-if="mode === 'prompt'" class="action-field"><span>{{ message }}</span><input v-model="value" class="input" autofocus /></label>
|
||||
<p v-else class="action-message">{{ message }}</p>
|
||||
<footer>
|
||||
<button type="button" class="button-secondary" :autofocus="mode === 'confirm'" @click="emit('resolve', null)">{{ t('取消', 'Cancel') }}</button>
|
||||
<button type="submit" class="button-primary">{{ t('确定', 'Confirm') }}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-dialog { width: min(520px, 100%); }
|
||||
h2 { margin: var(--space-sm) 0 var(--space-lg); }
|
||||
.action-field { display: grid; gap: var(--space-md); }
|
||||
.action-message, .action-field span { white-space: pre-wrap; overflow-wrap: anywhere; line-height: var(--line-height-relaxed); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-sm); margin-top: var(--space-xl); }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
const mounted: VueWrapper[] = []
|
||||
@@ -34,3 +34,17 @@ it('does not dismiss permission or busy dialogs through Escape or backdrop', asy
|
||||
await w.get('dialog').trigger('click')
|
||||
expect(w.emitted('close')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cycles Tab between the first and last visible controls', async () => {
|
||||
const w = mount(AppDialog, {props:{label:'键盘'}, slots:{default:'<section class="modal"><input /><button>取消</button><button disabled>禁用</button></section>'},attachTo:document.body}); mounted.push(w)
|
||||
const input = w.get('input').element
|
||||
const button = w.get('button').element
|
||||
const rects = [new DOMRect(0, 0, 50, 30)] as unknown as DOMRectList
|
||||
const spies = [input, button].map(element => vi.spyOn(element, 'getClientRects').mockReturnValue(rects))
|
||||
input.focus()
|
||||
await w.get('dialog').trigger('keydown', {key:'Tab', shiftKey:true})
|
||||
expect(document.activeElement).toBe(button)
|
||||
await w.get('dialog').trigger('keydown', {key:'Tab'})
|
||||
expect(document.activeElement).toBe(input)
|
||||
spies.forEach(spy => spy.mockRestore())
|
||||
})
|
||||
|
||||
@@ -9,6 +9,18 @@ let previousFocus: HTMLElement | null = null
|
||||
function dismiss() { if (props.dismissible) emit('close') }
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss() }
|
||||
if (event.key === 'Tab' && dialog.value) {
|
||||
const items = Array.from(dialog.value.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled):not([type="hidden"]), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex]'))
|
||||
.filter(element => element.tabIndex >= 0 && element.getClientRects().length > 0)
|
||||
const first = items[0]
|
||||
const last = items.at(-1)
|
||||
if (!first) { event.preventDefault(); dialog.value.focus(); return }
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.value)) {
|
||||
event.preventDefault(); last?.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault(); first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
previousFocus = document.activeElement as HTMLElement | null
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
const { actionDialog, resolveAction, askPrompt } = useActionDialog()
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
@@ -79,6 +83,7 @@ function hide() { open.value = false }
|
||||
async function execute(command: Command | undefined) {
|
||||
if (!command) return
|
||||
hide()
|
||||
await nextTick()
|
||||
try {
|
||||
await command.run()
|
||||
} catch (error) {
|
||||
@@ -87,7 +92,7 @@ async function execute(command: Command | undefined) {
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
const rawName = window.prompt(t('笔记名称', 'Note name'))?.trim()
|
||||
const rawName = (await askPrompt(t('笔记名称', 'Note name')))?.trim()
|
||||
if (!rawName) return
|
||||
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
||||
const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`)
|
||||
@@ -148,6 +153,7 @@ async function applyPluginEffect(effect: PluginCommandEffect) {
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
|
||||
if (!open.value && document.querySelector('dialog[open]')) return
|
||||
event.preventDefault()
|
||||
open.value ? hide() : show()
|
||||
} else if (event.key === 'Escape' && open.value) {
|
||||
@@ -160,12 +166,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<div v-if="commandNotice" class="command-toast" role="status">
|
||||
<span>{{ commandNotice }}</span><button :aria-label="t('关闭通知', 'Close notification')" @click="commandNotice = ''">×</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="command-backdrop" @click.self="hide">
|
||||
<section class="command-palette" role="dialog" aria-modal="true" :aria-label="t('命令面板', 'Command palette')">
|
||||
<AppDialog v-if="open" :label="t('命令面板', 'Command palette')" @close="hide">
|
||||
<section class="modal command-palette">
|
||||
<input ref="input" v-model="query" class="command-input" :placeholder="t('输入命令…', 'Enter a command…')" @keydown.enter.prevent="execute(filteredCommands[0])" />
|
||||
<p v-if="commandError" class="command-error">{{ commandError }}</p>
|
||||
<div class="command-list">
|
||||
@@ -176,15 +183,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</div>
|
||||
<footer><span>Enter · {{ t('执行', 'Run') }}</span><span>Esc · {{ t('关闭', 'Close') }}</span></footer>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.command-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: var(--color-background-overlay); animation: command-backdrop-in var(--motion-fast) both; }
|
||||
.command-palette { width: min(620px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: command-palette-in var(--motion-normal) both; }
|
||||
.command-palette { padding: 0; display: flex; flex-direction: column; width: min(620px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: command-palette-in var(--motion-normal) both; }
|
||||
.command-input { width: 100%; padding: var(--space-xl); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; color: var(--color-text-primary); font-size: var(--font-size-xl); }
|
||||
.command-list { max-height: 360px; overflow: auto; padding: var(--space-sm); }
|
||||
.command-list { min-height: 0; max-height: 360px; overflow: auto; padding: var(--space-sm); }
|
||||
.command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md) var(--space-lg); border-radius: var(--radius-md); text-align: left; transition: color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast); }
|
||||
.command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
||||
.command-list button:hover { transform: translateX(2px); }
|
||||
|
||||
@@ -6,12 +6,39 @@ import ExtensionInstallDialog from './ExtensionInstallDialog.vue'
|
||||
let wrapper: VueWrapper
|
||||
afterEach(() => { wrapper?.unmount() })
|
||||
|
||||
it.each(['Skill', 'Plugin'] as const)('uploads a selected %s ZIP only on confirmation', async kind => {
|
||||
const install = vi.fn().mockResolvedValue(undefined)
|
||||
wrapper = mount(ExtensionInstallDialog, { props: { kind, install } })
|
||||
const file = new File(['zip fixture'], 'package.zip', {type:'application/zip'})
|
||||
Object.defineProperty(wrapper.get('input[type="file"]').element, 'files', {value:[file], configurable:true})
|
||||
await wrapper.get('input[type="file"]').trigger('change')
|
||||
expect(wrapper.text()).toContain('package.zip')
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(install).toHaveBeenCalledExactlyOnceWith(file)
|
||||
expect(wrapper.emitted('installed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects oversized ZIP files before upload', async () => {
|
||||
const install = vi.fn()
|
||||
wrapper = mount(ExtensionInstallDialog, {props:{kind:'Skill',install}})
|
||||
const file = new File(['zip'], 'large.zip')
|
||||
Object.defineProperty(file, 'size', {value:10 * 1024 * 1024 + 1})
|
||||
Object.defineProperty(wrapper.get('input[type="file"]').element, 'files', {value:[file]})
|
||||
await wrapper.get('input[type="file"]').trigger('change')
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('10 MiB')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['Skill', 'Plugin'] as const)('installs %s from a trimmed directory and prevents duplicate submissions', async kind => {
|
||||
let complete!: () => void
|
||||
const install = vi.fn(() => new Promise<void>(resolve => { complete = resolve }))
|
||||
wrapper = mount(ExtensionInstallDialog, { props: { kind, install } })
|
||||
expect(wrapper.text()).toContain(`${kind.toLowerCase()}.yaml`)
|
||||
expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('button').find(button => button.text() === '本地目录')!.trigger('click')
|
||||
await wrapper.get('input').setValue(' G:\\packages\\example ')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
@@ -27,6 +54,7 @@ it.each(['Skill', 'Plugin'] as const)('installs %s from a trimmed directory and
|
||||
it('keeps the path and displays validation errors for retry', async () => {
|
||||
const install = vi.fn().mockRejectedValueOnce(new Error('Manifest does not exist')).mockResolvedValueOnce(undefined)
|
||||
wrapper = mount(ExtensionInstallDialog, { props: { kind: 'Plugin', install } })
|
||||
await wrapper.findAll('button').find(button => button.text() === '本地目录')!.trigger('click')
|
||||
await wrapper.get('input').setValue('G:\\packages\\example')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
@@ -5,20 +5,38 @@ import AppDialog from './AppDialog.vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const props = defineProps<{ kind: 'Skill' | 'Plugin'; install: (path: string) => Promise<unknown> }>()
|
||||
const props = defineProps<{ kind: 'Skill' | 'Plugin'; install: (source: string | File) => Promise<unknown> }>()
|
||||
const emit = defineEmits<{ close: []; installed: [] }>()
|
||||
const path = ref('')
|
||||
const mode = ref<'path' | 'zip'>('zip')
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const file = ref<File | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const title = computed(() => t(`安装 ${props.kind}`, `Install ${props.kind}`))
|
||||
const manifest = computed(() => `${props.kind.toLowerCase()}.yaml`)
|
||||
const ready = computed(() => mode.value === 'zip' ? Boolean(file.value) : Boolean(path.value.trim()))
|
||||
|
||||
function chooseFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
file.value = null
|
||||
error.value = ''
|
||||
const selected = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!selected) return
|
||||
if (!selected.name.toLowerCase().endsWith('.zip') || !selected.size || selected.size > 10 * 1024 * 1024) {
|
||||
error.value = t('请选择非空 ZIP 文件,大小不超过 10 MiB。', 'Choose a nonempty ZIP file up to 10 MiB.')
|
||||
return
|
||||
}
|
||||
file.value = selected
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (busy.value || !path.value.trim()) return
|
||||
if (busy.value || !ready.value) return
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
await props.install(path.value.trim())
|
||||
await props.install(mode.value === 'zip' ? file.value! : path.value.trim())
|
||||
emit('installed')
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : t('安装失败,请检查包目录后重试。', 'Installation failed. Check the package directory and retry.')
|
||||
@@ -33,8 +51,19 @@ async function submit() {
|
||||
<form class="modal extension-install-modal" :aria-busy="busy" @submit.prevent="submit">
|
||||
<span class="badge info">{{ t('扩展安装', 'Extension installation') }}</span>
|
||||
<h2>{{ title }}</h2>
|
||||
<p class="muted">{{ t('从本地包目录安装,安装时会校验清单与依赖。', 'Install from a local package directory. The manifest and dependencies are checked during installation.') }}</p>
|
||||
<div class="package-source">
|
||||
<p class="muted">{{ t('导入 ZIP 或使用本地包目录,安装时会校验清单与依赖。', 'Import a ZIP or use a local directory. The manifest and dependencies are checked during installation.') }}</p>
|
||||
<div class="source-tabs" :aria-label="t('安装来源', 'Installation source')">
|
||||
<button v-for="item in (['zip', 'path'] as const)" :key="item" type="button" class="button-secondary" :aria-pressed="mode === item" :disabled="busy" @click="mode = item; error = ''">{{ item === 'zip' ? t('ZIP 文件', 'ZIP file') : t('本地目录', 'Local directory') }}</button>
|
||||
</div>
|
||||
<div v-if="mode === 'zip'" class="package-source">
|
||||
<AppIcon :icon="FolderOpened" :size="30" />
|
||||
<input ref="fileInput" class="zip-input" type="file" accept=".zip,application/zip" :disabled="busy" :aria-label="t('选择 ZIP 扩展包', 'Choose a ZIP extension package')" @change="chooseFile" />
|
||||
<button type="button" class="button-secondary" :disabled="busy" @click="fileInput?.click()">{{ file ? t('重新选择 ZIP', 'Choose another ZIP') : t('选择 ZIP 文件', 'Choose ZIP file') }}</button>
|
||||
<strong v-if="file" class="package-name">{{ file.name }} · {{ (file.size / 1024).toFixed(1) }} KiB</strong>
|
||||
<p class="muted">{{ t('根目录或唯一顶层文件夹中须包含', 'The root or single top-level folder must contain') }} <code>{{ manifest }}</code></p>
|
||||
<p class="subtle">{{ t('ZIP 最大 10 MiB,解压后最大 50 MiB,最多 2048 个条目。', 'Up to 10 MiB compressed, 50 MiB extracted, and 2048 entries.') }}</p>
|
||||
</div>
|
||||
<div v-else class="package-source">
|
||||
<AppIcon :icon="FolderOpened" :size="30" />
|
||||
<strong>{{ t('本地包目录', 'Local package directory') }}</strong>
|
||||
<p class="muted">{{ t('选择包含以下清单的完整解压目录:', 'Use the extracted directory containing:') }} <code>{{ manifest }}</code></p>
|
||||
@@ -42,13 +71,13 @@ async function submit() {
|
||||
<span>{{ t('目录路径', 'Directory path') }}</span>
|
||||
<input v-model="path" class="input" autofocus required :disabled="busy" :placeholder="t('粘贴本地包目录的完整路径', 'Paste the full package directory path')" aria-describedby="extension-path-help" />
|
||||
</label>
|
||||
<p id="extension-path-help" class="subtle">{{ t('路径须位于 AI Core 所在电脑。ZIP 请先解压,再填写目录路径。', 'The directory must be on the AI Core computer. Extract ZIP packages before entering the directory path.') }}</p>
|
||||
<p id="extension-path-help" class="subtle">{{ t('路径须位于 AI Core 所在电脑。', 'The directory must be on the AI Core computer.') }}</p>
|
||||
</div>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
<p v-if="busy" class="muted" role="status">{{ t('正在校验并安装,请稍候…', 'Validating and installing…') }}</p>
|
||||
<footer class="install-actions">
|
||||
<button type="button" class="button-secondary" :disabled="busy" @click="emit('close')">{{ t('取消', 'Cancel') }}</button>
|
||||
<button type="submit" class="button-primary" :disabled="busy || !path.trim()">{{ busy ? t('安装中…', 'Installing…') : title }}</button>
|
||||
<button type="submit" class="button-primary" :disabled="busy || !ready">{{ busy ? t('安装中…', 'Installing…') : title }}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</AppDialog>
|
||||
@@ -63,4 +92,8 @@ h2 { margin: var(--space-sm) 0 var(--space-md); }
|
||||
.package-field input { min-width: 0; }
|
||||
.install-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-sm); margin-top: var(--space-lg); }
|
||||
.error-banner { overflow-wrap: anywhere; }
|
||||
.source-tabs { display: flex; gap: var(--space-sm); margin-top: var(--space-lg); }
|
||||
.source-tabs [aria-pressed="true"] { border-color: var(--color-accent-primary); color: var(--color-accent-primary); background: var(--color-accent-soft); }
|
||||
.zip-input { display: none; }
|
||||
.package-name { overflow-wrap: anywhere; max-width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nextTick, onBeforeUnmount, shallowRef } from 'vue'
|
||||
|
||||
export interface ActionDialogRequest { message: string; mode: 'confirm' | 'prompt'; initialValue: string }
|
||||
|
||||
/** Requests belong to the invoking view; leaving it cancels pending work. */
|
||||
export function useActionDialog() {
|
||||
const actionDialog = shallowRef<ActionDialogRequest | null>(null)
|
||||
let pending: ((value: string | null) => void) | undefined
|
||||
let disposed = false
|
||||
async function resolveAction(value: string | null) {
|
||||
const resolve = pending
|
||||
pending = undefined
|
||||
actionDialog.value = null
|
||||
await nextTick() // Restore focus and release the modal before the caller continues.
|
||||
resolve?.(disposed ? null : value)
|
||||
}
|
||||
function request(mode: ActionDialogRequest['mode'], message: string, initialValue = '') {
|
||||
if (disposed || pending) return Promise.resolve(null)
|
||||
actionDialog.value = { mode, message, initialValue }
|
||||
return new Promise<string | null>(resolve => { pending = resolve })
|
||||
}
|
||||
onBeforeUnmount(() => { disposed = true; pending?.(null); pending = undefined; actionDialog.value = null })
|
||||
return {
|
||||
actionDialog, resolveAction,
|
||||
askConfirm: async (message: string) => (await request('confirm', message)) !== null,
|
||||
askPrompt: (message: string, initialValue = '') => request('prompt', message, initialValue),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -84,6 +84,9 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
postBinary<T>(path: string, body: Blob) {
|
||||
return request<T>(path, { method: 'POST', body, headers: { 'Content-Type': 'application/zip' } })
|
||||
},
|
||||
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
|
||||
return request<T>(path, { ...options, method: 'GET' })
|
||||
},
|
||||
|
||||
@@ -50,8 +50,11 @@ export async function getPlugin(pluginId: string): Promise<Plugin> {
|
||||
return toPlugin(await apiClient.get<ApiPlugin>(`/api/plugins/${pluginId}`))
|
||||
}
|
||||
|
||||
export async function installPlugin(packagePath: string): Promise<Plugin> {
|
||||
return toPlugin(await apiClient.post<ApiPlugin>('/api/plugins/install', { package_path: packagePath }))
|
||||
export async function installPlugin(source: string | File): Promise<Plugin> {
|
||||
const installed = typeof source === 'string'
|
||||
? await apiClient.post<ApiPlugin>('/api/plugins/install', { package_path: source })
|
||||
: await apiClient.postBinary<ApiPlugin>('/api/plugins/install-zip', source)
|
||||
return toPlugin(installed)
|
||||
}
|
||||
|
||||
export async function enablePlugin(pluginId: string): Promise<Plugin> {
|
||||
|
||||
@@ -27,8 +27,11 @@ export async function getSkill(skillId: string): Promise<Skill> {
|
||||
return toSkill(await apiClient.get<ApiSkill>(`/api/skills/${skillId}`))
|
||||
}
|
||||
|
||||
export async function installSkill(packagePath: string): Promise<Skill> {
|
||||
return toSkill(await apiClient.post<ApiSkill>('/api/skills/install', { package_path: packagePath }))
|
||||
export async function installSkill(source: string | File): Promise<Skill> {
|
||||
const installed = typeof source === 'string'
|
||||
? await apiClient.post<ApiSkill>('/api/skills/install', { package_path: source })
|
||||
: await apiClient.postBinary<ApiSkill>('/api/skills/install-zip', source)
|
||||
return toSkill(installed)
|
||||
}
|
||||
|
||||
export async function enableSkill(skillId: string): Promise<Skill> {
|
||||
|
||||
@@ -34,7 +34,7 @@ export const usePluginStore = defineStore('plugin', () => {
|
||||
selectedPluginId.value = pluginId
|
||||
}
|
||||
|
||||
async function installPlugin(packagePath: string) {
|
||||
async function installPlugin(packagePath: string | File) {
|
||||
const installed = await pluginService.installPlugin(packagePath)
|
||||
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === installed.plugin_id)
|
||||
if (index >= 0) plugins.value[index] = installed
|
||||
|
||||
@@ -34,7 +34,7 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
selectedSkillId.value = skillId
|
||||
}
|
||||
|
||||
async function installSkill(packagePath: string) {
|
||||
async function installSkill(packagePath: string | File) {
|
||||
const installed = await skillService.installSkill(packagePath)
|
||||
const index = skills.value.findIndex((skill) => skill.skill_id === installed.skill_id)
|
||||
if (index >= 0) skills.value[index] = installed
|
||||
|
||||
Reference in New Issue
Block a user