🎨 移除扩展模块相关代码
This commit is contained in:
26
web-app-vue/package-lock.json
generated
26
web-app-vue/package-lock.json
generated
@@ -8,10 +8,12 @@
|
||||
"name": "web-app-vue",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.5",
|
||||
"dompurify": "^3.3.1",
|
||||
"element-plus": "^2.13.2",
|
||||
"jquery": "^4.0.0",
|
||||
"lodash": "^4.17.23",
|
||||
@@ -1291,6 +1293,15 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1340,6 +1351,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -1751,6 +1768,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.5",
|
||||
"dompurify": "^3.3.1",
|
||||
"element-plus": "^2.13.2",
|
||||
"jquery": "^4.0.0",
|
||||
"lodash": "^4.17.23",
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionListParams,
|
||||
ExtensionListResponse,
|
||||
CreateExtensionRequest,
|
||||
UpdateExtensionRequest,
|
||||
ToggleExtensionRequest,
|
||||
UpdateExtensionSettingsRequest,
|
||||
ExtensionStatsRequest,
|
||||
} from '@/types/extension'
|
||||
|
||||
// 创建扩展
|
||||
export function createExtension(data: CreateExtensionRequest) {
|
||||
return request<Extension>({
|
||||
url: '/app/extension',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 更新扩展
|
||||
export function updateExtension(id: number, data: UpdateExtensionRequest) {
|
||||
return request({
|
||||
url: `/app/extension/${id}`,
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 删除扩展
|
||||
export function deleteExtension(id: number, deleteFiles = false) {
|
||||
return request({
|
||||
url: `/app/extension/${id}`,
|
||||
method: 'delete',
|
||||
params: deleteFiles ? { deleteFiles: true } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取扩展详情
|
||||
export function getExtension(id: number) {
|
||||
return request<Extension>({
|
||||
url: `/app/extension/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取扩展列表
|
||||
export function getExtensionList(params?: ExtensionListParams) {
|
||||
return request<ExtensionListResponse>({
|
||||
url: '/app/extension',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取启用的扩展列表
|
||||
export function getEnabledExtensions() {
|
||||
return request<Extension[]>({
|
||||
url: '/app/extension/enabled',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 启用/禁用扩展
|
||||
export function toggleExtension(id: number, data: ToggleExtensionRequest) {
|
||||
return request({
|
||||
url: `/app/extension/${id}/toggle`,
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取扩展设置
|
||||
export function getExtensionSettings(id: number) {
|
||||
return request<Record<string, any>>({
|
||||
url: `/app/extension/${id}/settings`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 更新扩展设置
|
||||
export function updateExtensionSettings(id: number, data: UpdateExtensionSettingsRequest) {
|
||||
return request({
|
||||
url: `/app/extension/${id}/settings`,
|
||||
method: 'put',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取扩展 manifest
|
||||
export function getExtensionManifest(id: number) {
|
||||
return request<Record<string, any>>({
|
||||
url: `/app/extension/${id}/manifest`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 从 URL 安装扩展(智能识别 Git URL 或 Manifest URL)
|
||||
export function installExtensionFromUrl(url: string, branch = 'main') {
|
||||
return request<Extension>({
|
||||
url: '/app/extension/install/url',
|
||||
method: 'post',
|
||||
data: { url, branch },
|
||||
})
|
||||
}
|
||||
|
||||
// 从 Git URL 安装扩展
|
||||
export function installExtensionFromGit(gitUrl: string, branch = 'main') {
|
||||
return request<Extension>({
|
||||
url: '/app/extension/install/git',
|
||||
method: 'post',
|
||||
data: { url: gitUrl, branch },
|
||||
})
|
||||
}
|
||||
|
||||
// 升级扩展
|
||||
export function upgradeExtension(id: number, force = false) {
|
||||
return request<Extension>({
|
||||
url: `/app/extension/${id}/upgrade`,
|
||||
method: 'post',
|
||||
data: { force },
|
||||
})
|
||||
}
|
||||
|
||||
// 导入扩展(上传 zip 压缩包或文件夹)
|
||||
export function importExtension(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request<Extension>({
|
||||
url: '/app/extension/import',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 导出扩展
|
||||
export function exportExtension(id: number) {
|
||||
return request<Extension>({
|
||||
url: `/app/extension/${id}/export`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 更新扩展统计
|
||||
export function updateExtensionStats(data: ExtensionStatsRequest) {
|
||||
return request({
|
||||
url: `/app/extension/${data.extensionId}/stats`,
|
||||
method: 'post',
|
||||
data: {
|
||||
action: data.action,
|
||||
value: data.value || 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 下载扩展 JSON 文件
|
||||
export function downloadExtensionJSON(id: number, name: string) {
|
||||
return exportExtension(id).then((data) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${name || 'extension'}_${Date.now()}.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
1
web-app-vue/src/components.d.ts
vendored
1
web-app-vue/src/components.d.ts
vendored
@@ -59,7 +59,6 @@ declare module 'vue' {
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
ExtensionDrawer: typeof import('./components/ExtensionDrawer.vue')['default']
|
||||
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
@@ -1,904 +0,0 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
title="扩展管理"
|
||||
:size="600"
|
||||
direction="rtl"
|
||||
>
|
||||
<div class="extension-drawer-content">
|
||||
<!-- 扩展注入的设置面板(从全局 #extensions_settings 搬入显示) -->
|
||||
<div v-show="hasExtensionSettingsUI" class="extensions-settings-section">
|
||||
<el-divider content-position="left">扩展设置面板</el-divider>
|
||||
<div ref="settingsMountRef" class="extensions-settings-mount"></div>
|
||||
</div>
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索扩展..."
|
||||
clearable
|
||||
size="small"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-bar">
|
||||
<el-button size="small" @click="showInstallDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
安装扩展
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleUpdateAll"
|
||||
:disabled="!hasUpdatableExtensions"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
全部更新
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleRefresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 扩展折叠面板 -->
|
||||
<div class="extension-list" v-loading="extensionStore.loading">
|
||||
<el-empty v-if="filteredExtensions.length === 0" description="暂无扩展" />
|
||||
|
||||
<el-collapse v-else v-model="activeNames">
|
||||
<el-collapse-item
|
||||
v-for="ext in filteredExtensions"
|
||||
:key="ext.id"
|
||||
:name="ext.id"
|
||||
class="extension-item"
|
||||
>
|
||||
<!-- 扩展头部 -->
|
||||
<template #title>
|
||||
<div class="extension-header" @click.stop>
|
||||
<el-switch
|
||||
v-model="ext.isEnabled"
|
||||
size="default"
|
||||
@change="handleToggle(ext)"
|
||||
style="margin-right: 12px"
|
||||
/>
|
||||
<div class="extension-title">
|
||||
<span class="name">{{ ext.displayName || ext.name }}</span>
|
||||
<el-tag v-if="ext.isSystemExt" size="small" type="info" style="margin-left: 8px">
|
||||
系统
|
||||
</el-tag>
|
||||
<el-tag :type="getRunningStatus(ext).type" size="small" style="margin-left: 8px">
|
||||
{{ getRunningStatus(ext).text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 扩展内容 -->
|
||||
<div class="extension-content">
|
||||
<div class="extension-description" v-if="ext.description">
|
||||
<p>{{ ext.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 扩展配置 -->
|
||||
<el-divider content-position="left">配置</el-divider>
|
||||
|
||||
<!-- 自定义配置容器(扩展可以在运行时注入 UI) -->
|
||||
<div
|
||||
:id="`extension-settings-${ext.name}`"
|
||||
class="extension-custom-settings"
|
||||
:data-extension-id="ext.id"
|
||||
:data-extension-name="ext.name"
|
||||
>
|
||||
<!-- 扩展的自定义配置 UI 将动态注入到这里 -->
|
||||
</div>
|
||||
|
||||
<!-- 基于 manifest schema 的配置表单(兜底方案) -->
|
||||
<el-form label-position="top" size="small">
|
||||
<template v-for="(setting, key) in getSettingsSchema(ext)" :key="key">
|
||||
<!-- Text 输入框 -->
|
||||
<el-form-item
|
||||
v-if="setting.type === 'text' || !setting.type"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:placeholder="setting.placeholder"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Number 数字输入 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'number'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:min="setting.min"
|
||||
:max="setting.max"
|
||||
:step="setting.step || 1"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Boolean 开关 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'boolean'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-switch
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Select 下拉选择 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'select'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-select
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in setting.options"
|
||||
:key="option.value || option"
|
||||
:label="option.label || option"
|
||||
:value="option.value || option"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Textarea 文本域 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'textarea'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
type="textarea"
|
||||
:rows="setting.rows || 3"
|
||||
:placeholder="setting.placeholder"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Slider 滑块 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'slider'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-slider
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:min="setting.min || 0"
|
||||
:max="setting.max || 100"
|
||||
:step="setting.step || 1"
|
||||
:show-input="true"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 没有配置项(仅当: 无 manifest schema 配置 且 无扩展注入的自定义 UI 且 无全局设置面板) -->
|
||||
<el-empty
|
||||
v-if="Object.keys(getSettingsSchema(ext)).length === 0 && !extensionsWithCustomUI.has(ext.name) && !hasExtensionSettingsUI"
|
||||
description="此扩展没有可配置项"
|
||||
:image-size="60"
|
||||
/>
|
||||
<div
|
||||
v-else-if="Object.keys(getSettingsSchema(ext)).length === 0 && hasExtensionSettingsUI"
|
||||
class="setting-hint" style="text-align: center; padding: 8px 0; color: var(--el-text-color-secondary);"
|
||||
>
|
||||
此扩展的设置面板已显示在上方「扩展设置面板」区域
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="extension-actions">
|
||||
<el-button
|
||||
v-if="ext.isEnabled && getRunningStatus(ext).isRunning"
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="handleReload(ext)"
|
||||
>
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
重新加载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleUpdate(ext)"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
更新
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!ext.isSystemExt"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(ext)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
卸载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安装扩展对话框 -->
|
||||
<el-dialog
|
||||
v-model="showInstallDialog"
|
||||
title="安装扩展"
|
||||
width="500px"
|
||||
append-to-body
|
||||
>
|
||||
<el-tabs v-model="installTab">
|
||||
<el-tab-pane label="上传文件" name="file">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:show-file-list="true"
|
||||
:limit="1"
|
||||
accept=".zip,.json"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将扩展包(.zip)或 manifest.json 拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="URL 安装" name="url">
|
||||
<el-form :model="installForm" label-width="120px">
|
||||
<el-form-item label="URL">
|
||||
<el-input
|
||||
v-model="installForm.url"
|
||||
placeholder="Git 仓库 URL 或 manifest.json URL"
|
||||
>
|
||||
<template #append>
|
||||
<el-tooltip content="支持 Git 仓库 URL(如 GitHub、GitLab)或直接的 manifest.json URL" placement="top">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="分支(可选)">
|
||||
<el-input
|
||||
v-model="installForm.branch"
|
||||
placeholder="main"
|
||||
>
|
||||
<template #append>
|
||||
<el-tooltip content="Git 仓库的分支名称,默认为 main" placement="top">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
<div style="font-size: 12px;">
|
||||
示例:<br />
|
||||
• https://github.com/user/extension.git<br />
|
||||
• https://gitlab.com/user/extension<br />
|
||||
• https://example.com/manifest.json
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showInstallDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleInstall" :loading="installing">
|
||||
安装
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useExtensionStore } from '@/stores/extension'
|
||||
import { extensionRuntime } from '@/utils/extensionRuntime'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Refresh,
|
||||
RefreshRight,
|
||||
Delete,
|
||||
UploadFilled,
|
||||
QuestionFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}>()
|
||||
|
||||
// Store
|
||||
const extensionStore = useExtensionStore()
|
||||
|
||||
// State
|
||||
const drawerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val),
|
||||
})
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const activeNames = ref<number[]>([])
|
||||
const extensionSettings = reactive<Record<number, Record<string, any>>>({})
|
||||
const showInstallDialog = ref(false)
|
||||
const installTab = ref('file')
|
||||
const installing = ref(false)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const installForm = reactive({
|
||||
url: '',
|
||||
branch: 'main',
|
||||
})
|
||||
|
||||
// 扩展设置面板挂载点
|
||||
const settingsMountRef = ref<HTMLElement | null>(null)
|
||||
const hasExtensionSettingsUI = ref(false)
|
||||
|
||||
// 跟踪哪些扩展已经注入了自定义 UI
|
||||
const extensionsWithCustomUI = reactive(new Set<string>())
|
||||
let settingsObserver: MutationObserver | null = null
|
||||
|
||||
/**
|
||||
* 将全局 #extensions_settings 容器中的扩展 UI 搬入 Drawer 中显示
|
||||
* 原版 SillyTavern 扩展通过 jQuery 向 #extensions_settings 追加自定义 HTML,
|
||||
* 我们在 Drawer 打开时把这些内容移入显示,关闭时归还。
|
||||
*/
|
||||
const mountExtensionSettings = () => {
|
||||
const globalContainer = document.getElementById('extensions_settings')
|
||||
const globalContainer2 = document.getElementById('extensions_settings2')
|
||||
const mountPoint = settingsMountRef.value
|
||||
if (!mountPoint) return
|
||||
|
||||
const hasContent = (globalContainer && globalContainer.children.length > 0) ||
|
||||
(globalContainer2 && globalContainer2.children.length > 0)
|
||||
|
||||
if (hasContent) {
|
||||
// 将全局容器移入 Drawer 挂载点中(保持 DOM 引用不变,扩展的事件绑定不会丢失)
|
||||
if (globalContainer && globalContainer.children.length > 0) {
|
||||
globalContainer.style.display = ''
|
||||
mountPoint.appendChild(globalContainer)
|
||||
}
|
||||
if (globalContainer2 && globalContainer2.children.length > 0) {
|
||||
globalContainer2.style.display = ''
|
||||
mountPoint.appendChild(globalContainer2)
|
||||
}
|
||||
hasExtensionSettingsUI.value = true
|
||||
} else {
|
||||
hasExtensionSettingsUI.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扩展设置容器归还到 body(Drawer 关闭时调用)
|
||||
*/
|
||||
const unmountExtensionSettings = () => {
|
||||
const globalContainer = document.getElementById('extensions_settings')
|
||||
const globalContainer2 = document.getElementById('extensions_settings2')
|
||||
|
||||
if (globalContainer) {
|
||||
globalContainer.style.display = 'none'
|
||||
document.body.appendChild(globalContainer)
|
||||
}
|
||||
if (globalContainer2) {
|
||||
globalContainer2.style.display = 'none'
|
||||
document.body.appendChild(globalContainer2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查每个已注册扩展的 per-extension 容器是否有内容
|
||||
* 并监听全局 #extensions_settings 容器变化
|
||||
*/
|
||||
const scanCustomSettingsUI = () => {
|
||||
// 检查每个扩展的专属容器
|
||||
for (const ext of extensionStore.extensions) {
|
||||
const container = document.getElementById(`extension-settings-${ext.name}`)
|
||||
if (container && container.children.length > 0) {
|
||||
extensionsWithCustomUI.add(ext.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新检测全局容器
|
||||
const globalContainer = document.getElementById('extensions_settings')
|
||||
const globalContainer2 = document.getElementById('extensions_settings2')
|
||||
const hasContent = (globalContainer && globalContainer.children.length > 0) ||
|
||||
(globalContainer2 && globalContainer2.children.length > 0)
|
||||
hasExtensionSettingsUI.value = hasContent
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 MutationObserver 监听全局 #extensions_settings 容器变化
|
||||
* 扩展可能在加载完成后异步注入设置 UI,需要动态检测
|
||||
*/
|
||||
const startSettingsObserver = () => {
|
||||
if (settingsObserver) settingsObserver.disconnect()
|
||||
|
||||
settingsObserver = new MutationObserver(() => {
|
||||
scanCustomSettingsUI()
|
||||
// 如果 Drawer 已打开,重新尝试挂载
|
||||
if (drawerVisible.value) {
|
||||
mountExtensionSettings()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听全局容器
|
||||
const targets = [
|
||||
document.getElementById('extensions_settings'),
|
||||
document.getElementById('extensions_settings2'),
|
||||
].filter(Boolean) as HTMLElement[]
|
||||
|
||||
for (const target of targets) {
|
||||
settingsObserver.observe(target, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
// 也监听 body 上的 per-extension 容器变化(扩展可能通过 id 定位并注入)
|
||||
settingsObserver.observe(document.body, { childList: true, subtree: false })
|
||||
}
|
||||
|
||||
// 组件卸载时归还容器并清理 Observer
|
||||
onBeforeUnmount(() => {
|
||||
unmountExtensionSettings()
|
||||
if (settingsObserver) {
|
||||
settingsObserver.disconnect()
|
||||
settingsObserver = null
|
||||
}
|
||||
})
|
||||
|
||||
// Computed
|
||||
const filteredExtensions = computed(() => {
|
||||
let extensions = extensionStore.extensions
|
||||
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
extensions = extensions.filter(ext =>
|
||||
(ext.name?.toLowerCase().includes(keyword)) ||
|
||||
(ext.displayName?.toLowerCase().includes(keyword)) ||
|
||||
(ext.description?.toLowerCase().includes(keyword))
|
||||
)
|
||||
}
|
||||
|
||||
return extensions
|
||||
})
|
||||
|
||||
// 是否有可更新的扩展
|
||||
const hasUpdatableExtensions = computed(() => {
|
||||
return extensionStore.extensions.some(ext =>
|
||||
ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')
|
||||
)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const getSettingsSchema = (ext: Extension) => {
|
||||
try {
|
||||
const manifest = typeof ext.manifestData === 'string'
|
||||
? JSON.parse(ext.manifestData)
|
||||
: ext.manifestData
|
||||
return manifest?.settings || {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const getRunningStatus = (ext: Extension) => {
|
||||
const instance = extensionRuntime.getInstance(ext.id)
|
||||
if (!ext.isEnabled) {
|
||||
return { text: '已禁用', type: 'info', isRunning: false }
|
||||
}
|
||||
if (instance?.isRunning) {
|
||||
return { text: '运行中', type: 'success', isRunning: true }
|
||||
}
|
||||
if (instance?.isLoaded) {
|
||||
return { text: '已加载', type: 'warning', isRunning: false }
|
||||
}
|
||||
return { text: '未加载', type: 'danger', isRunning: false }
|
||||
}
|
||||
|
||||
const handleToggle = async (ext: Extension) => {
|
||||
try {
|
||||
// 传递当前的启用状态
|
||||
await extensionStore.toggleExtension(ext.id, ext.isEnabled)
|
||||
|
||||
// 根据状态加载/卸载扩展
|
||||
if (ext.isEnabled) {
|
||||
const success = await extensionRuntime.enableExtension(ext)
|
||||
if (!success) {
|
||||
ext.isEnabled = false
|
||||
ElMessage.error('启用扩展失败')
|
||||
} else {
|
||||
ElMessage.success('扩展已启用并运行')
|
||||
}
|
||||
} else {
|
||||
await extensionRuntime.disableExtension(ext.id)
|
||||
ElMessage.success('扩展已禁用')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换扩展状态失败:', error)
|
||||
// 恢复状态
|
||||
ext.isEnabled = !ext.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
const handleSettingChange = async (extId: number, key: string) => {
|
||||
try {
|
||||
await extensionStore.updateExtensionSettings(extId, {
|
||||
[key]: extensionSettings[extId][key],
|
||||
})
|
||||
|
||||
// 通知扩展配置已更改
|
||||
extensionRuntime.emitEvent('settingsChanged', {
|
||||
extensionId: extId,
|
||||
key,
|
||||
value: extensionSettings[extId][key],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error)
|
||||
ElMessage.error('保存设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReload = async (ext: Extension) => {
|
||||
try {
|
||||
await extensionRuntime.reloadExtension(ext)
|
||||
ElMessage.success('扩展已重新加载')
|
||||
} catch (error) {
|
||||
console.error('重新加载失败:', error)
|
||||
ElMessage.error('重新加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (ext: Extension) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要更新扩展 "${ext.displayName || ext.name}" 吗?这将从原始安装源重新下载扩展。`,
|
||||
'确认更新',
|
||||
{
|
||||
type: 'info',
|
||||
confirmButtonText: '更新',
|
||||
cancelButtonText: '取消',
|
||||
}
|
||||
)
|
||||
|
||||
// 更新扩展
|
||||
await extensionStore.upgradeExtension(ext.id)
|
||||
|
||||
// 如果扩展正在运行,重新加载
|
||||
if (ext.isEnabled) {
|
||||
await extensionRuntime.reloadExtension(ext)
|
||||
}
|
||||
|
||||
ElMessage.success('扩展已更新')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('更新失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (ext: Extension) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要卸载扩展 "${ext.displayName || ext.name}" 吗?`,
|
||||
'确认卸载',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
|
||||
// 先卸载运行时
|
||||
await extensionRuntime.unloadExtension(ext.id)
|
||||
|
||||
// 再删除数据
|
||||
await extensionStore.deleteExtension(ext.id)
|
||||
|
||||
ElMessage.success('扩展已卸载')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('卸载失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
extensionStore.fetchExtensionList()
|
||||
}
|
||||
|
||||
// 批量更新所有扩展
|
||||
const handleUpdateAll = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要更新所有可更新的扩展吗?',
|
||||
'批量更新',
|
||||
{
|
||||
type: 'info',
|
||||
confirmButtonText: '更新',
|
||||
cancelButtonText: '取消',
|
||||
}
|
||||
)
|
||||
|
||||
const updatableExtensions = extensionStore.extensions.filter(ext =>
|
||||
ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')
|
||||
)
|
||||
|
||||
if (updatableExtensions.length === 0) {
|
||||
ElMessage.info('没有可更新的扩展')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info(`开始更新 ${updatableExtensions.length} 个扩展...`)
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const ext of updatableExtensions) {
|
||||
try {
|
||||
await extensionStore.upgradeExtension(ext.id)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error(`更新扩展 ${ext.name} 失败:`, error)
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
ElMessage.success(`成功更新 ${successCount} 个扩展`)
|
||||
} else {
|
||||
ElMessage.warning(`更新完成:成功 ${successCount} 个,失败 ${failCount} 个`)
|
||||
}
|
||||
|
||||
// 重新加载所有启用的扩展
|
||||
await extensionStore.fetchExtensionList()
|
||||
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('批量更新失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (file: any) => {
|
||||
selectedFile.value = file.raw
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
installing.value = true
|
||||
|
||||
if (installTab.value === 'file') {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择扩展文件(.zip 或 .json)')
|
||||
return
|
||||
}
|
||||
await extensionStore.importExtension(selectedFile.value)
|
||||
} else {
|
||||
if (!installForm.url) {
|
||||
ElMessage.warning('请输入 Manifest URL')
|
||||
return
|
||||
}
|
||||
|
||||
// 从 URL 安装扩展(支持分支参数)
|
||||
await installFromURL(installForm.url, installForm.branch || 'main')
|
||||
}
|
||||
|
||||
showInstallDialog.value = false
|
||||
selectedFile.value = null
|
||||
installForm.url = ''
|
||||
installForm.branch = 'main'
|
||||
} catch (error) {
|
||||
console.error('安装失败:', error)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 从 URL 安装扩展(智能识别 Git URL 或 Manifest URL)
|
||||
const installFromURL = async (url: string, branch = 'main') => {
|
||||
try {
|
||||
// 验证 URL 格式
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
ElMessage.error('请输入有效的 HTTP(S) URL')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info('正在安装扩展...')
|
||||
|
||||
// 调用后端智能安装 API(自动识别 Git URL 或 Manifest URL)
|
||||
await extensionStore.installExtensionFromUrl(url, branch)
|
||||
|
||||
ElMessage.success('扩展安装成功')
|
||||
} catch (error: any) {
|
||||
console.error('安装失败:', error)
|
||||
ElMessage.error(error.message || '安装失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 监听扩展列表变化,初始化设置
|
||||
watch(
|
||||
() => extensionStore.extensions,
|
||||
(extensions) => {
|
||||
extensions.forEach(ext => {
|
||||
if (!extensionSettings[ext.id]) {
|
||||
extensionSettings[ext.id] = {}
|
||||
}
|
||||
|
||||
// 加载已保存的设置
|
||||
if (ext.settings) {
|
||||
const settings = typeof ext.settings === 'string'
|
||||
? JSON.parse(ext.settings)
|
||||
: ext.settings
|
||||
Object.assign(extensionSettings[ext.id], settings)
|
||||
}
|
||||
|
||||
// 如果扩展已启用,自动加载
|
||||
if (ext.isEnabled && !extensionRuntime.getInstance(ext.id)) {
|
||||
extensionRuntime.enableExtension(ext)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 当抽屉打开时刷新列表并挂载扩展设置 UI
|
||||
watch(drawerVisible, async (visible) => {
|
||||
if (visible) {
|
||||
handleRefresh()
|
||||
// 等待 DOM 更新后再挂载扩展设置面板
|
||||
await nextTick()
|
||||
// 启动 MutationObserver 以检测扩展动态注入的 UI
|
||||
startSettingsObserver()
|
||||
// 扫描已有的自定义 settings UI
|
||||
scanCustomSettingsUI()
|
||||
setTimeout(() => {
|
||||
mountExtensionSettings()
|
||||
// 再次扫描(给扩展一点时间注入 UI)
|
||||
scanCustomSettingsUI()
|
||||
}, 200)
|
||||
} else {
|
||||
unmountExtensionSettings()
|
||||
if (settingsObserver) {
|
||||
settingsObserver.disconnect()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.extension-drawer-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extensions-settings-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.extensions-settings-mount {
|
||||
width: 100%;
|
||||
|
||||
:deep(#extensions_settings),
|
||||
:deep(#extensions_settings2) {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.extension_container) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.inline-drawer) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.extension-item {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.extension-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.extension-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extension-content {
|
||||
.extension-description {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.extension-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -38,17 +38,6 @@
|
||||
<span>AI 配置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 扩展快捷按钮 -->
|
||||
<el-button
|
||||
v-if="authStore.isLoggedIn"
|
||||
text
|
||||
@click="extensionDrawerVisible = true"
|
||||
class="extension-menu-button"
|
||||
>
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>扩展</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@@ -85,26 +74,19 @@
|
||||
<el-main class="layout-main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
|
||||
<!-- 全局扩展抽屉 -->
|
||||
<ExtensionDrawer v-model:visible="extensionDrawerVisible" />
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Grid, Files, Reading, Connection, MagicStick, Setting, ChatDotRound } from '@element-plus/icons-vue'
|
||||
import ExtensionDrawer from '@/components/ExtensionDrawer.vue'
|
||||
import { Grid, Files, Reading, MagicStick, Setting, ChatDotRound } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 扩展抽屉状态
|
||||
const extensionDrawerVisible = ref(false)
|
||||
|
||||
// 初始化用户信息
|
||||
authStore.initUserInfo()
|
||||
|
||||
@@ -176,15 +158,6 @@ function handleCommand(command: string) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.extension-menu-button {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
|
||||
.el-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
|
||||
@@ -7,13 +7,7 @@ import router from './router'
|
||||
import App from './App.vue'
|
||||
import './assets/styles/index.scss'
|
||||
|
||||
// SillyTavern 扩展兼容:注入 jQuery 到全局(扩展 JS 依赖 $ 和 jQuery)
|
||||
import jQuery from 'jquery'
|
||||
; (window as any).$ = jQuery
|
||||
; (window as any).jQuery = jQuery
|
||||
|
||||
// 引入兼容层 (lodash, toastr)
|
||||
import '@/utils/compatibility'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
@@ -76,18 +76,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/worldbook/WorldBookEdit.vue'),
|
||||
meta: { title: '编辑世界书', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'extension',
|
||||
name: 'ExtensionList',
|
||||
component: () => import('@/views/extension/ExtensionListNew.vue'),
|
||||
meta: { title: '扩展管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'extension/settings/:id',
|
||||
name: 'ExtensionSettings',
|
||||
component: () => import('@/views/extension/ExtensionSettings.vue'),
|
||||
meta: { title: '扩展配置', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'regex',
|
||||
name: 'RegexScriptList',
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as extensionApi from '@/api/extension'
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionListParams,
|
||||
CreateExtensionRequest,
|
||||
UpdateExtensionRequest,
|
||||
} from '@/types/extension'
|
||||
|
||||
export const useExtensionStore = defineStore('extension', () => {
|
||||
// 状态
|
||||
const extensions = ref<Extension[]>([])
|
||||
const enabledExtensions = ref<Extension[]>([])
|
||||
const currentExtension = ref<Extension | null>(null)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
|
||||
// 过滤条件
|
||||
const filters = ref<ExtensionListParams>({
|
||||
extensionType: '',
|
||||
category: '',
|
||||
isEnabled: undefined,
|
||||
tag: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取扩展列表
|
||||
*/
|
||||
const fetchExtensionList = async (params?: ExtensionListParams) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const requestParams: ExtensionListParams = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
...filters.value,
|
||||
...params,
|
||||
}
|
||||
|
||||
const response = await extensionApi.getExtensionList(requestParams)
|
||||
extensions.value = response.data.list
|
||||
total.value = response.data.total
|
||||
currentPage.value = response.data.page
|
||||
pageSize.value = response.data.pageSize
|
||||
} catch (error: any) {
|
||||
console.error('获取扩展列表失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取扩展列表失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的扩展列表
|
||||
*/
|
||||
const fetchEnabledExtensions = async () => {
|
||||
try {
|
||||
const response = await extensionApi.getEnabledExtensions()
|
||||
enabledExtensions.value = response.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取启用扩展列表失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展详情
|
||||
*/
|
||||
const fetchExtension = async (id: number) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.getExtension(id)
|
||||
currentExtension.value = response.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取扩展详情失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取扩展详情失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建/安装扩展
|
||||
*/
|
||||
const createExtension = async (data: CreateExtensionRequest) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.createExtension(data)
|
||||
ElMessage.success('安装成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('创建扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '创建扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新扩展
|
||||
*/
|
||||
const updateExtension = async (id: number, data: UpdateExtensionRequest) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await extensionApi.updateExtension(id, data)
|
||||
ElMessage.success('更新成功')
|
||||
await fetchExtensionList()
|
||||
} catch (error: any) {
|
||||
console.error('更新扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '更新扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除/卸载扩展
|
||||
*/
|
||||
const deleteExtension = async (id: number, deleteFiles = false) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await extensionApi.deleteExtension(id, deleteFiles)
|
||||
ElMessage.success('卸载成功')
|
||||
await fetchExtensionList()
|
||||
} catch (error: any) {
|
||||
console.error('删除扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '删除扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用扩展
|
||||
*/
|
||||
const toggleExtension = async (id: number, isEnabled: boolean) => {
|
||||
try {
|
||||
await extensionApi.toggleExtension(id, { isEnabled })
|
||||
ElMessage.success(isEnabled ? '启用成功' : '禁用成功')
|
||||
await fetchExtensionList()
|
||||
await fetchEnabledExtensions()
|
||||
} catch (error: any) {
|
||||
console.error('切换扩展状态失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '操作失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展配置
|
||||
*/
|
||||
const getExtensionSettings = async (id: number) => {
|
||||
try {
|
||||
const response = await extensionApi.getExtensionSettings(id)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取扩展配置失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取扩展配置失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新扩展配置
|
||||
*/
|
||||
const updateExtensionSettings = async (id: number, settings: Record<string, any>) => {
|
||||
try {
|
||||
await extensionApi.updateExtensionSettings(id, { settings })
|
||||
ElMessage.success('配置更新成功')
|
||||
} catch (error: any) {
|
||||
console.error('更新扩展配置失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '更新配置失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展 manifest
|
||||
*/
|
||||
const getExtensionManifest = async (id: number) => {
|
||||
try {
|
||||
const response = await extensionApi.getExtensionManifest(id)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取扩展 manifest 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入扩展
|
||||
*/
|
||||
const importExtension = async (file: File) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.importExtension(file)
|
||||
ElMessage.success('导入成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('导入扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '导入扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出扩展
|
||||
*/
|
||||
const exportExtension = async (id: number, name: string) => {
|
||||
try {
|
||||
const response = await extensionApi.exportExtension(id)
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response.data], { type: 'application/json' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${name}_manifest.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('导出成功')
|
||||
} catch (error: any) {
|
||||
console.error('导出扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '导出扩展失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能安装扩展(自动识别 Git URL 或 Manifest URL)
|
||||
*/
|
||||
const installExtensionFromUrl = async (url: string, branch = 'main') => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.installExtensionFromUrl(url, branch)
|
||||
ElMessage.success('安装成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('安装扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || error.message || '安装扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Git URL 安装扩展
|
||||
*/
|
||||
const installExtensionFromGit = async (gitUrl: string, branch = 'main') => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.installExtensionFromGit(gitUrl, branch)
|
||||
ElMessage.success('安装成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('从 Git 安装扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '安装扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级扩展版本(从源重新安装)
|
||||
*/
|
||||
const upgradeExtension = async (id: number, force = false) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.upgradeExtension(id, force)
|
||||
ElMessage.success('升级成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('升级扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '升级扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新扩展统计
|
||||
*/
|
||||
const updateStats = async (extensionId: number, action: 'usage' | 'error' | 'load', value = 1) => {
|
||||
try {
|
||||
await extensionApi.updateExtensionStats({ extensionId, action, value })
|
||||
} catch (error: any) {
|
||||
console.error('更新扩展统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置过滤条件
|
||||
*/
|
||||
const setFilters = (newFilters: Partial<ExtensionListParams>) => {
|
||||
filters.value = { ...filters.value, ...newFilters }
|
||||
currentPage.value = 1
|
||||
fetchExtensionList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置过滤条件
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
extensionType: '',
|
||||
category: '',
|
||||
isEnabled: undefined,
|
||||
tag: '',
|
||||
}
|
||||
currentPage.value = 1
|
||||
fetchExtensionList()
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
extensions,
|
||||
enabledExtensions,
|
||||
currentExtension,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
loading,
|
||||
filters,
|
||||
|
||||
// 操作
|
||||
fetchExtensionList,
|
||||
fetchEnabledExtensions,
|
||||
fetchExtension,
|
||||
createExtension,
|
||||
updateExtension,
|
||||
deleteExtension,
|
||||
toggleExtension,
|
||||
getExtensionSettings,
|
||||
updateExtensionSettings,
|
||||
getExtensionManifest,
|
||||
importExtension,
|
||||
exportExtension,
|
||||
installExtensionFromUrl,
|
||||
installExtensionFromGit,
|
||||
upgradeExtension,
|
||||
updateStats,
|
||||
setFilters,
|
||||
resetFilters,
|
||||
}
|
||||
})
|
||||
129
web-app-vue/src/types/extension.d.ts
vendored
129
web-app-vue/src/types/extension.d.ts
vendored
@@ -1,129 +0,0 @@
|
||||
// 扩展类型定义(兼容 SillyTavern Extension 规范)
|
||||
|
||||
export interface Extension {
|
||||
id: number
|
||||
userId: number
|
||||
name: string
|
||||
displayName: string
|
||||
version: string
|
||||
author: string
|
||||
description: string
|
||||
homepage: string
|
||||
repository: string
|
||||
license: string
|
||||
tags: string[]
|
||||
extensionType: 'ui' | 'server' | 'hybrid'
|
||||
category: 'utilities' | 'themes' | 'integrations' | 'tools' | string
|
||||
dependencies: Record<string, string>
|
||||
conflicts: string[]
|
||||
manifestData: Record<string, any>
|
||||
scriptPath: string
|
||||
stylePath: string
|
||||
assetsPaths: string[]
|
||||
settings: Record<string, any>
|
||||
options: Record<string, any>
|
||||
isEnabled: boolean
|
||||
isInstalled: boolean
|
||||
isSystemExt: boolean
|
||||
installSource: 'url' | 'git' | 'file' | 'marketplace'
|
||||
sourceUrl: string // 原始安装 URL(用于更新)
|
||||
branch: string // Git 分支
|
||||
autoUpdate: boolean // 是否自动更新
|
||||
lastUpdateCheck?: string // 最后检查更新时间
|
||||
availableVersion?: string // 可用的新版本
|
||||
installDate: string
|
||||
lastEnabled: string
|
||||
usageCount: number
|
||||
errorCount: number
|
||||
loadTime: number
|
||||
metadata: Record<string, any>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ExtensionManifest {
|
||||
name: string
|
||||
display_name?: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
homepage?: string
|
||||
repository?: string
|
||||
license?: string
|
||||
tags?: string[]
|
||||
type?: 'ui' | 'server' | 'hybrid'
|
||||
category?: string
|
||||
dependencies?: Record<string, string>
|
||||
conflicts?: string[]
|
||||
entry?: string
|
||||
style?: string
|
||||
assets?: string[]
|
||||
settings?: Record<string, any>
|
||||
options?: Record<string, any>
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface CreateExtensionRequest {
|
||||
name: string
|
||||
displayName?: string
|
||||
version?: string
|
||||
author?: string
|
||||
description?: string
|
||||
homepage?: string
|
||||
repository?: string
|
||||
license?: string
|
||||
tags?: string[]
|
||||
extensionType: 'ui' | 'server' | 'hybrid'
|
||||
category?: string
|
||||
dependencies?: Record<string, string>
|
||||
conflicts?: string[]
|
||||
manifestData: Record<string, any>
|
||||
scriptPath?: string
|
||||
stylePath?: string
|
||||
assetsPaths?: string[]
|
||||
settings?: Record<string, any>
|
||||
options?: Record<string, any>
|
||||
installSource?: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface UpdateExtensionRequest {
|
||||
displayName?: string
|
||||
description?: string
|
||||
settings?: Record<string, any>
|
||||
options?: Record<string, any>
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ExtensionListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
name?: string
|
||||
extensionType?: string
|
||||
category?: string
|
||||
isEnabled?: boolean
|
||||
isInstalled?: boolean
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export interface ExtensionListResponse {
|
||||
list: Extension[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ToggleExtensionRequest {
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
export interface UpdateExtensionSettingsRequest {
|
||||
settings: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ExtensionStatsRequest {
|
||||
extensionId: number
|
||||
action: 'usage' | 'error' | 'load'
|
||||
value?: number
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import _ from 'lodash'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// SillyTavern 扩展兼容层:注入全局库
|
||||
// 原版扩展依赖 jquery, lodash, toastr 等全局变量
|
||||
// 全局库兼容层:注入 lodash 和 toastr
|
||||
// 这些库可能被其他模块使用
|
||||
|
||||
// 1. jQuery (需要在 main.ts 中引入,因为它是通过 import jQuery form 'jquery' 引入的,这里再引入一份可能导致多例,但挂载 window 是安全的)
|
||||
// 由于 main.ts 已经处理了 jQuery,这里暂不处理,或者为了统一可以移过来。
|
||||
// 目前 main.ts 已经有了,就先不管 jQuery。
|
||||
|
||||
// 2. Lodash
|
||||
// Lodash
|
||||
if (!(window as any)._) {
|
||||
; (window as any)._ = _
|
||||
}
|
||||
|
||||
// 3. Toastr (使用 ElementPlus Message 模拟)
|
||||
// Toastr (使用 ElementPlus Message 模拟)
|
||||
if (!(window as any).toastr) {
|
||||
; (window as any).toastr = {
|
||||
info: (msg: string) => ElMessage.info(msg),
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
/**
|
||||
* SillyTavern 扩展运行时环境
|
||||
* 负责加载、运行和管理扩展的生命周期
|
||||
*/
|
||||
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
/**
|
||||
* SillyTavern 扩展兼容层:注入扩展依赖的全局 API
|
||||
* 原版 SillyTavern 扩展依赖 window.toastr、lodash 等全局库
|
||||
*/
|
||||
function initGlobalCompat() {
|
||||
// toastr 兼容层(使用 ElMessage 替代)
|
||||
if (!(window as any).toastr) {
|
||||
; (window as any).toastr = {
|
||||
info: (msg: string) => ElMessage.info(msg),
|
||||
success: (msg: string) => ElMessage.success(msg),
|
||||
warning: (msg: string) => ElMessage.warning(msg),
|
||||
error: (msg: string) => ElMessage.error(msg),
|
||||
clear: () => { },
|
||||
}
|
||||
}
|
||||
|
||||
// 创建扩展设置容器(原版 SillyTavern 扩展通过 jQuery 向这些容器追加设置面板 UI)
|
||||
// ExtensionDrawer.vue 在打开时会将这些容器移入 Drawer 中显示
|
||||
if (!document.getElementById('extensions_settings')) {
|
||||
const container = document.createElement('div')
|
||||
container.id = 'extensions_settings'
|
||||
container.style.display = 'none'
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
if (!document.getElementById('extensions_settings2')) {
|
||||
const container2 = document.createElement('div')
|
||||
container2.id = 'extensions_settings2'
|
||||
container2.style.display = 'none'
|
||||
document.body.appendChild(container2)
|
||||
}
|
||||
}
|
||||
|
||||
// 立即初始化兼容层
|
||||
initGlobalCompat()
|
||||
|
||||
interface ExtensionInstance {
|
||||
extension: Extension
|
||||
manifest: any
|
||||
scriptElement?: HTMLScriptElement | HTMLStyleElement
|
||||
styleElement?: HTMLStyleElement
|
||||
isLoaded: boolean
|
||||
isRunning: boolean
|
||||
api?: any
|
||||
}
|
||||
|
||||
class ExtensionRuntime {
|
||||
private instances: Map<number, ExtensionInstance> = new Map()
|
||||
private stAPI: any = null
|
||||
// 扩展设置存储(兼容原版 SillyTavern 的 extension_settings)
|
||||
private extensionSettings: Record<string, any> = {}
|
||||
|
||||
constructor() {
|
||||
this.initSillyTavernAPI()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展的配置容器 DOM 元素
|
||||
*/
|
||||
private getExtensionSettingsContainer(extensionName: string): HTMLElement | null {
|
||||
return document.getElementById(`extension-settings-${extensionName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展的已保存设置
|
||||
*/
|
||||
private loadExtensionSettings(extensionName: string): any {
|
||||
if (!this.extensionSettings[extensionName]) {
|
||||
const key = `ext_settings_${extensionName}`
|
||||
const saved = localStorage.getItem(key)
|
||||
if (saved) {
|
||||
try {
|
||||
this.extensionSettings[extensionName] = JSON.parse(saved)
|
||||
} catch (e) {
|
||||
console.error(`[ExtensionRuntime] 加载设置失败: ${extensionName}`, e)
|
||||
this.extensionSettings[extensionName] = {}
|
||||
}
|
||||
} else {
|
||||
this.extensionSettings[extensionName] = {}
|
||||
}
|
||||
}
|
||||
return this.extensionSettings[extensionName]
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存扩展设置到 localStorage
|
||||
*/
|
||||
private saveExtensionSettingsToStorage(extensionName: string) {
|
||||
const key = `ext_settings_${extensionName}`
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(this.extensionSettings[extensionName] || {}))
|
||||
} catch (e) {
|
||||
console.error(`[ExtensionRuntime] 保存设置失败: ${extensionName}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 SillyTavern API(兼容层)
|
||||
*/
|
||||
private initSillyTavernAPI() {
|
||||
// 创建全局 SillyTavern API 对象
|
||||
this.stAPI = {
|
||||
// 扩展管理
|
||||
extensions: {
|
||||
register: (name: string, init: Function) => {
|
||||
console.log(`[Extension] 注册扩展: ${name}`)
|
||||
// 执行扩展的初始化函数
|
||||
try {
|
||||
init()
|
||||
} catch (error) {
|
||||
console.error(`[Extension] 初始化失败: ${name}`, error)
|
||||
}
|
||||
},
|
||||
|
||||
// 获取扩展设置
|
||||
getSettings: (extName: string) => {
|
||||
// 从 localStorage 或 store 获取设置
|
||||
const settings = localStorage.getItem(`ext_settings_${extName}`)
|
||||
return settings ? JSON.parse(settings) : {}
|
||||
},
|
||||
|
||||
// 保存扩展设置
|
||||
saveSettings: (extName: string, settings: any) => {
|
||||
localStorage.setItem(`ext_settings_${extName}`, JSON.stringify(settings))
|
||||
// 触发设置变更事件
|
||||
this.emitEvent('extensionSettingsLoaded', { name: extName })
|
||||
},
|
||||
|
||||
// 获取扩展列表
|
||||
list: () => {
|
||||
return Array.from(this.instances.values()).map(inst => ({
|
||||
name: inst.extension.name,
|
||||
displayName: inst.extension.displayName,
|
||||
version: inst.extension.version,
|
||||
enabled: inst.extension.isEnabled,
|
||||
loaded: inst.isLoaded,
|
||||
running: inst.isRunning,
|
||||
}))
|
||||
},
|
||||
},
|
||||
|
||||
// UI 工具
|
||||
ui: {
|
||||
// 创建设置面板
|
||||
createSettings: (title: string, _content: HTMLElement) => {
|
||||
console.log(`[Extension] 创建设置面板: ${title}`)
|
||||
// 可以在这里创建一个设置面板并添加到页面
|
||||
},
|
||||
|
||||
// 显示通知
|
||||
notify: (message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') => {
|
||||
ElMessage[type](message)
|
||||
},
|
||||
|
||||
// 添加 UI 元素到指定位置
|
||||
addElement: (element: HTMLElement, targetSelector: string) => {
|
||||
const target = document.querySelector(targetSelector)
|
||||
if (target) {
|
||||
target.appendChild(element)
|
||||
}
|
||||
},
|
||||
|
||||
// 创建按钮
|
||||
createButton: (text: string, onClick: Function) => {
|
||||
const button = document.createElement('button')
|
||||
button.textContent = text
|
||||
button.onclick = onClick as any
|
||||
return button
|
||||
},
|
||||
},
|
||||
|
||||
// 事件系统
|
||||
eventSource: {
|
||||
on: (event: string, callback: Function) => {
|
||||
window.addEventListener(`st:${event}`, (e: any) => callback(e.detail))
|
||||
},
|
||||
|
||||
once: (event: string, callback: Function) => {
|
||||
const handler = (e: any) => {
|
||||
callback(e.detail)
|
||||
window.removeEventListener(`st:${event}`, handler)
|
||||
}
|
||||
window.addEventListener(`st:${event}`, handler)
|
||||
},
|
||||
|
||||
off: (event: string, callback?: Function) => {
|
||||
if (callback) {
|
||||
window.removeEventListener(`st:${event}`, callback as any)
|
||||
}
|
||||
},
|
||||
|
||||
emit: (event: string, data?: any) => {
|
||||
window.dispatchEvent(new CustomEvent(`st:${event}`, { detail: data }))
|
||||
},
|
||||
},
|
||||
|
||||
// 获取当前上下文
|
||||
getContext: () => {
|
||||
// 尝试从路由或 store 获取当前上下文
|
||||
const context = {
|
||||
characterId: null as number | null,
|
||||
chatId: null as number | null,
|
||||
groupId: null as number | null,
|
||||
userName: '',
|
||||
characterName: '',
|
||||
|
||||
// extension_settings 对象(兼容原版 SillyTavern)
|
||||
extension_settings: new Proxy(this.extensionSettings, {
|
||||
get: (target, prop: string) => {
|
||||
if (typeof prop === 'string') {
|
||||
// 自动加载设置(如果还没加载)
|
||||
this.loadExtensionSettings(prop)
|
||||
return target[prop] || {}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
set: (target, prop: string, value) => {
|
||||
if (typeof prop === 'string') {
|
||||
target[prop] = value
|
||||
// 自动保存到 localStorage
|
||||
this.saveExtensionSettingsToStorage(prop)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}),
|
||||
|
||||
// 获取扩展配置容器的方法
|
||||
getExtensionSettingsContainer: (extensionName: string) => {
|
||||
return this.getExtensionSettingsContainer(extensionName)
|
||||
},
|
||||
|
||||
// 原版酒馆:保存设置(防抖),扩展会调用
|
||||
saveSettingsDebounced: () => {
|
||||
// 我们通过 extension_settings 的 set 已自动存 localStorage,此处可触发事件通知
|
||||
this.emitEvent('settingsSaved')
|
||||
},
|
||||
|
||||
saveSettings: () => {
|
||||
this.emitEvent('settingsSaved')
|
||||
},
|
||||
}
|
||||
|
||||
// 可以从实际应用状态获取
|
||||
try {
|
||||
const route = (window as any).$route
|
||||
if (route) {
|
||||
context.characterId = route.params.characterId ? parseInt(route.params.characterId) : null
|
||||
context.chatId = route.params.chatId ? parseInt(route.params.chatId) : null
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return context
|
||||
},
|
||||
|
||||
// 工具函数
|
||||
utils: {
|
||||
// 延迟执行
|
||||
delay: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)),
|
||||
|
||||
// 生成唯一 ID
|
||||
generateId: () => `ext_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
|
||||
// 安全解析 JSON
|
||||
parseJSON: (str: string, fallback: any = null) => {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// 存储工具
|
||||
storage: {
|
||||
get: (key: string, defaultValue: any = null) => {
|
||||
const value = localStorage.getItem(`st_${key}`)
|
||||
return value ? JSON.parse(value) : defaultValue
|
||||
},
|
||||
|
||||
set: (key: string, value: any) => {
|
||||
localStorage.setItem(`st_${key}`, JSON.stringify(value))
|
||||
},
|
||||
|
||||
remove: (key: string) => {
|
||||
localStorage.removeItem(`st_${key}`)
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
const keys = Object.keys(localStorage).filter(k => k.startsWith('st_'))
|
||||
keys.forEach(k => localStorage.removeItem(k))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 原版酒馆:渲染扩展 HTML 模板(扩展会调用)
|
||||
// 注意:大部分扩展通过 import { renderExtensionTemplateAsync } from '...extensions.js' 使用原版实现,
|
||||
// 此处仅作为后备兼容(如果扩展直接调用 window.SillyTavern.renderExtensionTemplateAsync)
|
||||
this.stAPI.renderExtensionTemplateAsync = async (extensionName: string, templateId: string, _templateData: Record<string, any> = {}) => {
|
||||
// 与原版 SillyTavern 一致:模板路径为 /scripts/extensions/{extensionName}/{templateId}.html
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888'
|
||||
const url = `${apiBase}/scripts/extensions/${extensionName}/${templateId}.html`
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return document.createElement('div')
|
||||
const html = await res.text()
|
||||
const wrap = document.createElement('div')
|
||||
wrap.innerHTML = html
|
||||
return wrap.firstElementChild || wrap
|
||||
} catch (e) {
|
||||
console.warn(`[ExtensionRuntime] 加载模板失败: ${url}`, e)
|
||||
return document.createElement('div')
|
||||
}
|
||||
}
|
||||
|
||||
this.stAPI.renderExtensionTemplate = (_extensionName: string, _templateId: string, _templateData: Record<string, any> = {}) => {
|
||||
console.warn('[ExtensionRuntime] renderExtensionTemplate 同步版未实现,请使用 renderExtensionTemplateAsync')
|
||||
return document.createElement('div')
|
||||
}
|
||||
|
||||
// 挂载到 window 对象
|
||||
; (window as any).SillyTavern = this.stAPI
|
||||
; (window as any).st = this.stAPI // 简写别名
|
||||
|
||||
// 触发 API 初始化完成事件
|
||||
this.emitEvent('apiReady')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展
|
||||
*/
|
||||
async loadExtension(extension: Extension): Promise<boolean> {
|
||||
try {
|
||||
console.log(`[Extension] 加载扩展: ${extension.name}`)
|
||||
|
||||
// 检查是否已加载
|
||||
if (this.instances.has(extension.id) && this.instances.get(extension.id)?.isLoaded) {
|
||||
console.log(`[Extension] 扩展已加载: ${extension.name}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 解析 manifest
|
||||
const manifest = typeof extension.manifestData === 'string'
|
||||
? JSON.parse(extension.manifestData)
|
||||
: extension.manifestData
|
||||
|
||||
const instance: ExtensionInstance = {
|
||||
extension,
|
||||
manifest,
|
||||
isLoaded: false,
|
||||
isRunning: false,
|
||||
}
|
||||
|
||||
// 加载样式文件
|
||||
if (extension.stylePath) {
|
||||
await this.loadStyle(extension, instance)
|
||||
}
|
||||
|
||||
// 加载脚本文件
|
||||
if (extension.scriptPath) {
|
||||
await this.loadScript(extension, instance)
|
||||
} else if (manifest.inline_script) {
|
||||
// 支持内联脚本
|
||||
await this.loadInlineScript(extension, instance, manifest.inline_script)
|
||||
}
|
||||
|
||||
instance.isLoaded = true
|
||||
this.instances.set(extension.id, instance)
|
||||
|
||||
console.log(`[Extension] 加载成功: ${extension.name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[Extension] 加载失败: ${extension.name}`, error)
|
||||
ElMessage.error(`加载扩展失败: ${extension.name}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展样式
|
||||
* 资源路由为公开路由,直接使用 <link> 标签加载(与原版 SillyTavern 一致)
|
||||
*/
|
||||
private async loadStyle(extension: Extension, instance: ExtensionInstance): Promise<void> {
|
||||
const url = this.getAssetURL(extension, extension.stylePath!)
|
||||
console.log(`[Extension] 加载样式: ${extension.name}, URL=${url}`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = url
|
||||
link.dataset.extension = extension.name
|
||||
|
||||
link.onload = () => {
|
||||
instance.styleElement = link as any
|
||||
resolve()
|
||||
}
|
||||
|
||||
link.onerror = () => {
|
||||
reject(new Error(`样式加载失败: ${extension.stylePath}`))
|
||||
}
|
||||
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展脚本
|
||||
* 与原版 SillyTavern 一致:使用 <script type="module"> 加载 ES module 格式的扩展脚本。
|
||||
* 不能用 Blob URL,因为 ES module 的 import 语句使用相对路径,
|
||||
* 需要从实际 URL 加载才能正确解析(如 ../../../../../scripts/utils.js -> /scripts/utils.js)。
|
||||
* 资源路由为公开路由,不需要 JWT header。
|
||||
*/
|
||||
private async loadScript(extension: Extension, instance: ExtensionInstance): Promise<void> {
|
||||
const url = this.getAssetURL(extension, extension.scriptPath!)
|
||||
console.log(`[Extension] 加载脚本: ${extension.name}, URL=${url}`)
|
||||
|
||||
// 记录 base URL 供扩展内部使用(renderExtensionTemplateAsync 用)
|
||||
const baseUrl = url.replace(/\/[^/]*$/, '')
|
||||
; (window as any).__extensionBaseUrl = (window as any).__extensionBaseUrl || {}
|
||||
; (window as any).__extensionBaseUrl[extension.name] = baseUrl
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.type = 'module'
|
||||
script.src = url
|
||||
script.dataset.extension = extension.name
|
||||
script.async = true
|
||||
|
||||
script.onload = () => {
|
||||
instance.scriptElement = script
|
||||
instance.isRunning = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
script.onerror = (e) => {
|
||||
console.error(`[Extension] 脚本加载详细错误:`, e)
|
||||
reject(new Error(`脚本加载失败: ${extension.scriptPath} (请检查控制台 Network 标签页的响应)`))
|
||||
}
|
||||
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载内联脚本
|
||||
*/
|
||||
private async loadInlineScript(extension: Extension, instance: ExtensionInstance, scriptCode: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const script = document.createElement('script')
|
||||
script.textContent = scriptCode
|
||||
script.dataset.extension = extension.name
|
||||
script.dataset.inline = 'true'
|
||||
|
||||
document.body.appendChild(script)
|
||||
|
||||
instance.scriptElement = script
|
||||
instance.isRunning = true
|
||||
resolve()
|
||||
} catch (error) {
|
||||
reject(new Error(`内联脚本执行失败: ${error}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载扩展
|
||||
*/
|
||||
async unloadExtension(extensionId: number): Promise<boolean> {
|
||||
const instance = this.instances.get(extensionId)
|
||||
if (!instance) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Extension] 卸载扩展: ${instance.extension.name}`)
|
||||
|
||||
// 移除样式
|
||||
if (instance.styleElement) {
|
||||
instance.styleElement.remove()
|
||||
}
|
||||
|
||||
// 移除脚本
|
||||
if (instance.scriptElement) {
|
||||
instance.scriptElement.remove()
|
||||
}
|
||||
|
||||
instance.isLoaded = false
|
||||
instance.isRunning = false
|
||||
|
||||
this.instances.delete(extensionId)
|
||||
|
||||
console.log(`[Extension] 卸载成功: ${instance.extension.name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[Extension] 卸载失败: ${instance.extension.name}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用扩展
|
||||
*/
|
||||
async enableExtension(extension: Extension): Promise<boolean> {
|
||||
if (!extension.isEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
return await this.loadExtension(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用扩展
|
||||
*/
|
||||
async disableExtension(extensionId: number): Promise<boolean> {
|
||||
return await this.unloadExtension(extensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载扩展
|
||||
*/
|
||||
async reloadExtension(extension: Extension): Promise<boolean> {
|
||||
await this.unloadExtension(extension.id)
|
||||
return await this.loadExtension(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源 URL(扩展文件存储在后端本地,通过 API 接口访问)
|
||||
*/
|
||||
private getAssetURL(extension: Extension, path: string): string {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path
|
||||
}
|
||||
// 与原版 SillyTavern 完全一致的路径结构:/scripts/extensions/third-party/{name}/{path}
|
||||
// 这样扩展 JS 中的相对路径 import(如 ../../../extensions.js)能正确解析
|
||||
// 资源由后端 Router.Static("/scripts", ...) 直接提供,无需额外 API 路由
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888'
|
||||
return `${apiBase}/scripts/extensions/third-party/${extension.name}/${path.replace(/^\//, '')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展实例
|
||||
*/
|
||||
getInstance(extensionId: number): ExtensionInstance | undefined {
|
||||
return this.instances.get(extensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已加载的扩展
|
||||
*/
|
||||
getLoadedExtensions(): ExtensionInstance[] {
|
||||
return Array.from(this.instances.values()).filter(inst => inst.isLoaded)
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发扩展事件
|
||||
*/
|
||||
emitEvent(event: string, data?: any) {
|
||||
this.stAPI.eventSource.emit(event, data)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例
|
||||
export const extensionRuntime = new ExtensionRuntime()
|
||||
|
||||
// 导出类型
|
||||
export type { ExtensionInstance }
|
||||
@@ -1,441 +0,0 @@
|
||||
<template>
|
||||
<div class="extension-list">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索和操作栏 -->
|
||||
<div class="header-actions">
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchParams.keyword"
|
||||
placeholder="搜索扩展名称、描述"
|
||||
style="width: 300px"
|
||||
clearable
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-select
|
||||
v-model="searchParams.extensionType"
|
||||
placeholder="类型"
|
||||
style="width: 120px; margin-left: 10px"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="UI 扩展" value="ui" />
|
||||
<el-option label="服务端" value="server" />
|
||||
<el-option label="混合" value="hybrid" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="searchParams.isEnabled"
|
||||
placeholder="状态"
|
||||
style="width: 120px; margin-left: 10px"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="已启用" :value="true" />
|
||||
<el-option label="已禁用" :value="false" />
|
||||
</el-select>
|
||||
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch" style="margin-left: 10px">
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:before-upload="handleImport"
|
||||
accept=".zip,.json"
|
||||
>
|
||||
<el-button :icon="Upload">导入扩展</el-button>
|
||||
</el-upload>
|
||||
|
||||
<el-button :icon="Link" @click="showInstallDialog = true">
|
||||
从 URL 安装
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 扩展卡片列表 -->
|
||||
<div v-loading="extensionStore.loading" class="extension-cards">
|
||||
<el-empty v-if="extensionStore.extensions.length === 0 && !extensionStore.loading" description="暂无扩展" />
|
||||
|
||||
<div v-for="ext in extensionStore.extensions" :key="ext.id" class="extension-card">
|
||||
<div class="card-header">
|
||||
<div class="card-info">
|
||||
<div class="card-title">
|
||||
<span class="ext-name">{{ ext.displayName || ext.name }}</span>
|
||||
<el-tag size="small" type="info">v{{ ext.version }}</el-tag>
|
||||
<el-tag v-if="ext.isSystemExt" size="small" type="warning">系统</el-tag>
|
||||
<el-tag size="small" :type="typeTagColor(ext.extensionType)">
|
||||
{{ typeLabel(ext.extensionType) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span v-if="ext.author" class="meta-item">
|
||||
<el-icon><User /></el-icon> {{ ext.author }}
|
||||
</span>
|
||||
<span v-if="ext.category" class="meta-item">
|
||||
<el-icon><FolderOpened /></el-icon> {{ ext.category }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="ext.description" class="card-desc">{{ ext.description }}</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-switch
|
||||
v-model="ext.isEnabled"
|
||||
@change="handleToggle(ext)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="card-tags" v-if="ext.tags && ext.tags.length">
|
||||
<el-tag
|
||||
v-for="tag in ext.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-ops">
|
||||
<el-button
|
||||
type="primary"
|
||||
text
|
||||
size="small"
|
||||
:icon="Setting"
|
||||
@click="handleSettings(ext.id)"
|
||||
>
|
||||
设置
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
text
|
||||
size="small"
|
||||
:icon="Download"
|
||||
@click="handleExport(ext)"
|
||||
>
|
||||
导出
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
title="确定要删除这个扩展吗?"
|
||||
@confirm="handleDelete(ext.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button
|
||||
type="danger"
|
||||
text
|
||||
size="small"
|
||||
:icon="Delete"
|
||||
:disabled="ext.isSystemExt"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="extensionStore.currentPage"
|
||||
v-model:page-size="extensionStore.pageSize"
|
||||
:total="extensionStore.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="handleSearch"
|
||||
@current-change="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 从 URL 安装对话框 -->
|
||||
<el-dialog v-model="showInstallDialog" title="从 URL 安装扩展" width="480px">
|
||||
<el-form :model="installForm" label-width="80px">
|
||||
<el-form-item label="URL 地址">
|
||||
<el-input
|
||||
v-model="installForm.url"
|
||||
placeholder="请输入 Git 仓库或 manifest.json 地址"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分支">
|
||||
<el-input
|
||||
v-model="installForm.branch"
|
||||
placeholder="main"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showInstallDialog = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="installLoading"
|
||||
@click="handleInstallFromUrl"
|
||||
>
|
||||
安装
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Search,
|
||||
Upload,
|
||||
Download,
|
||||
Delete,
|
||||
Setting,
|
||||
Link,
|
||||
User,
|
||||
FolderOpened,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useExtensionStore } from '@/stores/extension'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
const router = useRouter()
|
||||
const extensionStore = useExtensionStore()
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive({
|
||||
keyword: '',
|
||||
extensionType: undefined as string | undefined,
|
||||
isEnabled: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
// 安装对话框
|
||||
const showInstallDialog = ref(false)
|
||||
const installLoading = ref(false)
|
||||
const installForm = reactive({
|
||||
url: '',
|
||||
branch: 'main',
|
||||
})
|
||||
|
||||
// 类型标签颜色映射
|
||||
const typeTagColor = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ui: '',
|
||||
server: 'success',
|
||||
hybrid: 'warning',
|
||||
}
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
// 类型显示名
|
||||
const typeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ui: 'UI',
|
||||
server: '服务端',
|
||||
hybrid: '混合',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
extensionStore.currentPage = 1
|
||||
extensionStore.fetchExtensionList({
|
||||
keyword: searchParams.keyword || undefined,
|
||||
extensionType: searchParams.extensionType || undefined,
|
||||
isEnabled: searchParams.isEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
// 切换启用/禁用
|
||||
const handleToggle = async (ext: Extension) => {
|
||||
try {
|
||||
await extensionStore.toggleExtension(ext.id, ext.isEnabled)
|
||||
} catch (error) {
|
||||
ext.isEnabled = !ext.isEnabled
|
||||
console.error('切换状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置
|
||||
const handleSettings = (id: number) => {
|
||||
router.push(`/extension/settings/${id}`)
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await extensionStore.deleteExtension(id)
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
const handleExport = async (ext: Extension) => {
|
||||
try {
|
||||
await extensionStore.exportExtension(ext.id, ext.name)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入(支持 zip 和 json 文件)
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
await extensionStore.importExtension(file)
|
||||
} catch (error) {
|
||||
console.error('导入失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 从 URL 安装
|
||||
const handleInstallFromUrl = async () => {
|
||||
if (!installForm.url) {
|
||||
ElMessage.warning('请输入 URL 地址')
|
||||
return
|
||||
}
|
||||
installLoading.value = true
|
||||
try {
|
||||
await extensionStore.installExtensionFromUrl(
|
||||
installForm.url,
|
||||
installForm.branch || 'main',
|
||||
)
|
||||
showInstallDialog.value = false
|
||||
installForm.url = ''
|
||||
installForm.branch = 'main'
|
||||
} catch (error) {
|
||||
console.error('安装失败:', error)
|
||||
} finally {
|
||||
installLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
extensionStore.fetchExtensionList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.extension-list {
|
||||
padding: 20px;
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-cards {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.extension-card {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.ext-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card-ops {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,291 +0,0 @@
|
||||
<template>
|
||||
<div class="extension-settings">
|
||||
<el-page-header @back="goBack" style="margin-bottom: 20px">
|
||||
<template #content>
|
||||
<span class="page-title">{{ extensionName }} - 扩展配置</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 扩展信息 -->
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" v-loading="extensionStore.loading">
|
||||
<template #header>
|
||||
<span>扩展信息</span>
|
||||
</template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="名称">{{ extensionStore.currentExtension?.displayName || extensionStore.currentExtension?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="版本">{{ extensionStore.currentExtension?.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="作者">{{ extensionStore.currentExtension?.author || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag size="small">{{ typeLabel(extensionStore.currentExtension?.extensionType || '') }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">{{ extensionStore.currentExtension?.category || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="extensionStore.currentExtension?.isEnabled ? 'success' : 'info'" size="small">
|
||||
{{ extensionStore.currentExtension?.isEnabled ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装来源">{{ extensionStore.currentExtension?.installSource || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="许可证">{{ extensionStore.currentExtension?.license || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<p v-if="extensionStore.currentExtension?.description" class="ext-description">
|
||||
{{ extensionStore.currentExtension.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="extensionStore.currentExtension?.homepage || extensionStore.currentExtension?.repository" class="ext-links">
|
||||
<el-link
|
||||
v-if="extensionStore.currentExtension?.homepage"
|
||||
type="primary"
|
||||
:href="extensionStore.currentExtension.homepage"
|
||||
target="_blank"
|
||||
>
|
||||
主页
|
||||
</el-link>
|
||||
<el-link
|
||||
v-if="extensionStore.currentExtension?.repository"
|
||||
type="primary"
|
||||
:href="extensionStore.currentExtension.repository"
|
||||
target="_blank"
|
||||
>
|
||||
仓库
|
||||
</el-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 设置编辑 -->
|
||||
<el-col :span="16">
|
||||
<el-card shadow="never" v-loading="settingsLoading">
|
||||
<template #header>
|
||||
<div class="settings-header">
|
||||
<span>扩展设置</span>
|
||||
<div>
|
||||
<el-button size="small" @click="resetSettings">重置</el-button>
|
||||
<el-button type="primary" size="small" :loading="saving" @click="saveSettings">
|
||||
保存设置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="!settingsKeys.length && !settingsLoading" description="该扩展暂无可配置项" />
|
||||
|
||||
<el-form v-else label-width="160px" label-position="left">
|
||||
<el-form-item
|
||||
v-for="key in settingsKeys"
|
||||
:key="key"
|
||||
:label="key"
|
||||
>
|
||||
<!-- 布尔值 -->
|
||||
<el-switch
|
||||
v-if="typeof settingsData[key] === 'boolean'"
|
||||
v-model="settingsData[key]"
|
||||
/>
|
||||
<!-- 数字 -->
|
||||
<el-input-number
|
||||
v-else-if="typeof settingsData[key] === 'number'"
|
||||
v-model="settingsData[key]"
|
||||
:controls="true"
|
||||
/>
|
||||
<!-- 字符串 -->
|
||||
<el-input
|
||||
v-else-if="typeof settingsData[key] === 'string'"
|
||||
v-model="settingsData[key]"
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
type="textarea"
|
||||
/>
|
||||
<!-- 对象/数组 - JSON 编辑 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="settingsJsonStrings[key]"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||
@blur="parseJsonField(key)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- Manifest 数据 -->
|
||||
<el-card shadow="never" style="margin-top: 20px" v-loading="extensionStore.loading">
|
||||
<template #header>
|
||||
<span>Manifest 数据</span>
|
||||
</template>
|
||||
<pre v-if="manifest" class="manifest-json">{{ JSON.stringify(manifest, null, 2) }}</pre>
|
||||
<el-empty v-else description="无 Manifest 数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useExtensionStore } from '@/stores/extension'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const extensionStore = useExtensionStore()
|
||||
|
||||
const extensionId = computed(() => Number(route.params.id))
|
||||
|
||||
// 状态
|
||||
const settingsLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const settingsData = ref<Record<string, any>>({})
|
||||
const settingsJsonStrings = ref<Record<string, string>>({})
|
||||
const originalSettings = ref<Record<string, any>>({})
|
||||
const manifest = ref<Record<string, any> | null>(null)
|
||||
|
||||
const extensionName = computed(() =>
|
||||
extensionStore.currentExtension?.displayName || extensionStore.currentExtension?.name || '扩展',
|
||||
)
|
||||
|
||||
const settingsKeys = computed(() => Object.keys(settingsData.value))
|
||||
|
||||
// 类型标签
|
||||
const typeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ui: 'UI 扩展',
|
||||
server: '服务端扩展',
|
||||
hybrid: '混合扩展',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 获取扩展详情
|
||||
const fetchExtension = async () => {
|
||||
try {
|
||||
await extensionStore.fetchExtension(extensionId.value)
|
||||
} catch (error) {
|
||||
console.error('获取扩展详情失败:', error)
|
||||
ElMessage.error('获取扩展详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取扩展设置
|
||||
const fetchSettings = async () => {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const data = await extensionStore.getExtensionSettings(extensionId.value)
|
||||
if (data) {
|
||||
settingsData.value = { ...data }
|
||||
originalSettings.value = JSON.parse(JSON.stringify(data))
|
||||
|
||||
// 为复杂类型生成 JSON 字符串
|
||||
for (const key of Object.keys(data)) {
|
||||
if (typeof data[key] === 'object' && data[key] !== null) {
|
||||
settingsJsonStrings.value[key] = JSON.stringify(data[key], null, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设置失败:', error)
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 manifest
|
||||
const fetchManifest = async () => {
|
||||
try {
|
||||
const data = await extensionStore.getExtensionManifest(extensionId.value)
|
||||
if (data) {
|
||||
manifest.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 manifest 失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 JSON 字段
|
||||
const parseJsonField = (key: string) => {
|
||||
try {
|
||||
settingsData.value[key] = JSON.parse(settingsJsonStrings.value[key])
|
||||
} catch {
|
||||
ElMessage.warning(`字段 "${key}" 的 JSON 格式无效`)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await extensionStore.updateExtensionSettings(extensionId.value, settingsData.value)
|
||||
originalSettings.value = JSON.parse(JSON.stringify(settingsData.value))
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置设置
|
||||
const resetSettings = () => {
|
||||
settingsData.value = JSON.parse(JSON.stringify(originalSettings.value))
|
||||
for (const key of Object.keys(settingsData.value)) {
|
||||
if (typeof settingsData.value[key] === 'object' && settingsData.value[key] !== null) {
|
||||
settingsJsonStrings.value[key] = JSON.stringify(settingsData.value[key], null, 2)
|
||||
}
|
||||
}
|
||||
ElMessage.info('已重置')
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.push('/extension')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchExtension()
|
||||
fetchSettings()
|
||||
fetchManifest()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.extension-settings {
|
||||
padding: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ext-description {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ext-links {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.manifest-json {
|
||||
background: var(--el-fill-color-lighter);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
max-height: 400px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user