mirror of
https://github.com/certd/certd.git
synced 2026-08-02 02:44:49 +08:00
chore: plugin 有store page版本
This commit is contained in:
@@ -389,6 +389,9 @@ export class Executor {
|
||||
};
|
||||
await instance.setCtx(taskCtx);
|
||||
|
||||
if (!(instance instanceof AbstractTaskPlugin)) {
|
||||
throw new Error(`插件类型错误:${step.type}不是AbstractTaskPlugin的实例`);
|
||||
}
|
||||
await instance.onInstance();
|
||||
const result = await instance.execute();
|
||||
//执行结果处理
|
||||
@@ -398,6 +401,7 @@ export class Executor {
|
||||
}
|
||||
//输出上下文变量到output context
|
||||
forEach(define.output, (item: any, key: any) => {
|
||||
// @ts-ignore
|
||||
step.status!.output[key] = instance[key];
|
||||
// const stepOutputKey = `step.${step.id}.${key}`;
|
||||
// this.runtime.context[stepOutputKey] = instance[key];
|
||||
@@ -411,7 +415,8 @@ export class Executor {
|
||||
merge(vars, instance._result.pipelineVars);
|
||||
await this.pipelineContext.setObj("vars", vars);
|
||||
}
|
||||
if (Object.keys(instance._result.pipelinePrivateVars).length > 0) {
|
||||
// @ts-ignore
|
||||
if (Object.keys(instance._result?.pipelinePrivateVars).length > 0) {
|
||||
// 判断 pipelineVars 有值时更新
|
||||
let vars = await this.pipelineContext.getObj("privateVars");
|
||||
vars = vars || {};
|
||||
|
||||
@@ -164,7 +164,7 @@ export class PlusService {
|
||||
baseURL: plusRequestService.getBaseURL(),
|
||||
method: "post",
|
||||
headers: {
|
||||
Authorization: `Berear ${token}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const res = await http.request(config);
|
||||
@@ -173,4 +173,9 @@ export class PlusService {
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async request(config: HttpRequestConfig) {
|
||||
const plusRequestService = await this.getPlusRequestService();
|
||||
return await plusRequestService.request(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,10 @@ const projectStore = useProjectStore();
|
||||
<div v-if="!settingStore.isComm" class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full hidden md:block">
|
||||
<fs-button shape="circle" type="text" icon="ion:logo-github" :text="null" @click="goGithub" />
|
||||
</div>
|
||||
<MaxKBChat v-if="settingsStore.sysPublic.aiChatEnabled !== false" ref="chatBox" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<PageFooter></PageFooter>
|
||||
<MaxKBChat v-if="settingsStore.sysPublic.aiChatEnabled !== false" ref="chatBox" />
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
<router-view> </router-view>
|
||||
</template>
|
||||
|
||||
<!--<script lang="ts" setup>-->
|
||||
<!--import { usePageStore } from "/@/store/modules/page";-->
|
||||
<script lang="ts" setup>
|
||||
import { useSettingStore } from "../store/settings";
|
||||
|
||||
<!--const pageStore = usePageStore();-->
|
||||
<!--const keepAlive = pageStore.keepAlive;-->
|
||||
<!--</script>-->
|
||||
const settingsStore = useSettingStore();
|
||||
</script>
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
default: "Default",
|
||||
success: "Success",
|
||||
test: "Test",
|
||||
operation: "Operation",
|
||||
testButton: "Test",
|
||||
operationSuccess: "Operation successful",
|
||||
batch_delete: "Batch Delete",
|
||||
|
||||
@@ -42,6 +42,36 @@ export default {
|
||||
pluginGroup: "Plugin Group",
|
||||
pluginManagement: "Plugin Management",
|
||||
pluginBetaWarning: "Custom plugins are in BETA and may have breaking changes in future",
|
||||
localPlugin: "Installed",
|
||||
pluginMarket: "Plugin Market",
|
||||
installedStorePlugin: "Installed",
|
||||
onlinePluginManagement: "Online Plugins",
|
||||
onlinePluginSearch: "Search online plugins",
|
||||
onlinePluginSync: "Sync Plugin Market",
|
||||
onlinePluginSyncFirst: "Sync the plugin market list first, then search and install plugins",
|
||||
onlinePluginSyncSuccess: "Plugin market list synced successfully",
|
||||
onlinePluginLastSyncTime: "Last synced: {time}",
|
||||
onlinePluginNotSynced: "Plugin market has not been synced",
|
||||
onlinePluginRefresh: "Refresh List",
|
||||
onlinePluginInstall: "Install",
|
||||
onlinePluginUpdate: "Update",
|
||||
onlinePluginReinstall: "Reinstall",
|
||||
onlinePluginUninstall: "Uninstall",
|
||||
onlinePluginStatus: "Status",
|
||||
onlinePluginInstalled: "Installed",
|
||||
onlinePluginEnabled: "Enabled",
|
||||
onlinePluginDisabled: "Disabled",
|
||||
onlinePluginClickToEnable: "Click to enable",
|
||||
onlinePluginClickToDisable: "Click to disable",
|
||||
onlinePluginDownloadCount: "Downloads: {count}",
|
||||
onlinePluginNotInstalled: "Not Installed",
|
||||
onlinePluginUpgradeAvailable: "Upgradeable",
|
||||
onlinePluginCurrentVersion: "Current {version}",
|
||||
onlinePluginAlreadyLatest: "Already up to date",
|
||||
onlinePluginClickToUpdate: "Click to update",
|
||||
onlinePluginInstallSuccess: "Online plugin installed successfully",
|
||||
onlinePluginUninstallSuccess: "Online plugin uninstalled successfully",
|
||||
onlinePluginDeleteConfirm: 'Are you sure you want to uninstall online plugin "{name}"? Pipelines using this plugin may fail after deletion.',
|
||||
pleaseSelectRecord: "Please select records first",
|
||||
clearRuntimeDeps: "Clear Runtime Deps Cache",
|
||||
clearRuntimeDepsTooltip: "Restart the certd container after clearing, otherwise cached modules will not be reloaded",
|
||||
|
||||
@@ -50,6 +50,7 @@ export default {
|
||||
default: "默认",
|
||||
success: "成功",
|
||||
test: "测试",
|
||||
operation: "操作",
|
||||
testButton: "测试",
|
||||
operationSuccess: "操作成功",
|
||||
batch_delete: "批量删除",
|
||||
|
||||
@@ -41,6 +41,36 @@ export default {
|
||||
pluginGroup: "插件分组",
|
||||
pluginManagement: "插件管理",
|
||||
pluginBetaWarning: "自定义插件处于BETA测试版,后续可能会有破坏性变更",
|
||||
localPlugin: "本地插件",
|
||||
pluginMarket: "插件市场",
|
||||
installedStorePlugin: "已安装",
|
||||
onlinePluginManagement: "在线插件",
|
||||
onlinePluginSearch: "搜索在线插件",
|
||||
onlinePluginSync: "同步插件市场",
|
||||
onlinePluginSyncFirst: "请先同步插件市场列表,然后再搜索和安装插件",
|
||||
onlinePluginSyncSuccess: "插件市场列表同步成功",
|
||||
onlinePluginLastSyncTime: "上次同步时间:{time}",
|
||||
onlinePluginNotSynced: "尚未同步插件市场",
|
||||
onlinePluginRefresh: "刷新列表",
|
||||
onlinePluginInstall: "安装",
|
||||
onlinePluginUpdate: "更新",
|
||||
onlinePluginReinstall: "重新安装",
|
||||
onlinePluginUninstall: "卸载",
|
||||
onlinePluginStatus: "状态",
|
||||
onlinePluginInstalled: "已安装",
|
||||
onlinePluginEnabled: "已启用",
|
||||
onlinePluginDisabled: "已禁用",
|
||||
onlinePluginClickToEnable: "点击启用",
|
||||
onlinePluginClickToDisable: "点击禁用",
|
||||
onlinePluginDownloadCount: "下载量:{count}",
|
||||
onlinePluginNotInstalled: "未安装",
|
||||
onlinePluginUpgradeAvailable: "可更新",
|
||||
onlinePluginCurrentVersion: "当前 {version}",
|
||||
onlinePluginAlreadyLatest: "当前已是最新版本",
|
||||
onlinePluginClickToUpdate: "点击更新",
|
||||
onlinePluginInstallSuccess: "在线插件安装成功",
|
||||
onlinePluginUninstallSuccess: "在线插件卸载成功",
|
||||
onlinePluginDeleteConfirm: "确定要卸载在线插件「{name}」吗?如果该插件已被流水线使用,删除可能会导致执行失败。",
|
||||
pleaseSelectRecord: "请先勾选记录",
|
||||
clearRuntimeDeps: "清理第三方依赖缓存",
|
||||
clearRuntimeDepsTooltip: "清除后需重启 certd 容器,否则已缓存模块不会重新读取",
|
||||
|
||||
+57
-117
@@ -12,59 +12,21 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<fs-icon class="icon-button" :icon="fullscreen ? 'material-symbols:fullscreen-exit' : 'material-symbols:fullscreen'" @click="fullscreen = !fullscreen"></fs-icon>
|
||||
<a-space>
|
||||
<fs-icon class="icon-button" :icon="fullscreen ? 'material-symbols:fullscreen-exit' : 'material-symbols:fullscreen'" @click="fullscreen = !fullscreen"></fs-icon>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="currentStep">
|
||||
<pi-container v-if="currentStep._isAdd" class="pi-step-form">
|
||||
<template #header>
|
||||
<a-row :gutter="10" class="mb-10">
|
||||
<a-col :span="24" style="padding-left: 20px">
|
||||
<a-input-search v-model:value="pluginSearch.keyword" placeholder="搜索插件" :allow-clear="true" :show-search="true"></a-input-search>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
<div class="flex-col h-100 overflow-hidden md:ml-5 md:mr-5 step-form-body">
|
||||
<a-tabs v-model:active-key="pluginGroupActive" tab-position="left" class="flex-1 overflow-hidden h-full">
|
||||
<template v-for="group of computedPluginGroups" :key="group.key">
|
||||
<a-tab-pane v-if="(group.key === 'admin' && userStore.isAdmin) || group.key !== 'admin'" :key="group.key" class="scroll-y">
|
||||
<template #tab>
|
||||
<div class="cd-step-form-tab-label">
|
||||
<fs-icon :icon="group.icon" class="mr-2" />
|
||||
<div>{{ group.title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-row v-if="!group.plugins || group.plugins.length === 0" :gutter="10">
|
||||
<a-col class="flex-o">
|
||||
<div class="flex-o m-10">没有找到插件</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row v-else :gutter="10">
|
||||
<a-col v-for="item of group.plugins" :key="item.key" class="step-plugin w-full md:w-[50%]">
|
||||
<a-card
|
||||
hoverable
|
||||
:class="{ current: item.name === currentStep.type }"
|
||||
@click="stepTypeSelected(item)"
|
||||
@dblclick="
|
||||
stepTypeSelected(item);
|
||||
stepTypeSave();
|
||||
"
|
||||
>
|
||||
<a-card-meta>
|
||||
<template #title>
|
||||
<fs-icon class="plugin-icon" :icon="item.icon || 'clarity:plugin-line'"></fs-icon>
|
||||
<span class="title" :title="item.title">{{ item.title }}</span>
|
||||
<vip-button v-if="item.needPlus" mode="icon" />
|
||||
</template>
|
||||
<template #description>
|
||||
<span :title="item.desc" v-html="transformDesc(item.desc)"></span>
|
||||
</template>
|
||||
</a-card-meta>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
</template>
|
||||
<a-tabs v-model:active-key="pluginSourceActive" class="step-plugin-source-tabs flex-1 overflow-hidden h-full">
|
||||
<a-tab-pane key="local" tab="已安装插件" class="h-full">
|
||||
<LocalPluginSelector :selected-type="currentStep.type" @select="stepTypeSelected" @confirm="handlePluginConfirm" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane v-if="userStore.isAdmin" key="market" tab="插件市场" class="h-full step-market-pane">
|
||||
<OnlinePluginSelector :selected-type="currentStep.type" @select="stepTypeSelected" @confirm="handlePluginConfirm" @uninstalled="handleOnlinePluginUninstalled" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -117,28 +79,30 @@
|
||||
|
||||
<script lang="tsx" setup>
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import { computed, provide, ref, Ref, watch } from "vue";
|
||||
import { provide, ref, Ref } from "vue";
|
||||
import { merge, cloneDeep } from "lodash-es";
|
||||
import { nanoid } from "nanoid";
|
||||
import { usePluginStore, PluginGroups } from "/@/store/plugin";
|
||||
import { usePluginStore } from "/@/store/plugin";
|
||||
import { useCompute } from "@fast-crud/fast-crud";
|
||||
import { useReference } from "/@/use/use-refrence";
|
||||
import { useSettingStore } from "/@/store/settings";
|
||||
import { mitter } from "/@/utils/util.mitt";
|
||||
import { utils } from "/@/utils";
|
||||
import { useUserStore } from "/@/store/user";
|
||||
import LocalPluginSelector from "./local-plugin-selector.vue";
|
||||
import OnlinePluginSelector from "./online-plugin-selector.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "PiStepForm",
|
||||
});
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
defineEmits(["update"]);
|
||||
|
||||
const pluginStore = usePluginStore();
|
||||
const userStore = useUserStore();
|
||||
@@ -171,6 +135,9 @@ function useStepForm() {
|
||||
});
|
||||
|
||||
const stepTypeSelected = (item: any) => {
|
||||
if (item.__online) {
|
||||
return;
|
||||
}
|
||||
if (item.needPlus && !settingStore.isPlus) {
|
||||
message.warn("此插件需要开通Certd专业版才能使用");
|
||||
mitter.emit("openVipModal");
|
||||
@@ -339,55 +306,24 @@ function useStepForm() {
|
||||
};
|
||||
};
|
||||
|
||||
const pluginSearch = ref({
|
||||
keyword: "",
|
||||
result: [],
|
||||
});
|
||||
const pluginGroupActive = ref("all");
|
||||
const pluginGroup: Ref = ref();
|
||||
const pluginStore = usePluginStore();
|
||||
const pluginSourceActive = ref("local");
|
||||
const handlePluginConfirm = async (item: any) => {
|
||||
stepTypeSelected(item);
|
||||
await stepTypeSave();
|
||||
};
|
||||
|
||||
async function loadPluginGroups() {
|
||||
pluginGroup.value = await pluginStore.getGroups();
|
||||
}
|
||||
|
||||
loadPluginGroups();
|
||||
const computedPluginGroups: any = computed(() => {
|
||||
if (!pluginGroup.value) {
|
||||
return {};
|
||||
const handleOnlinePluginUninstalled = (plugin: any) => {
|
||||
if (currentStep.value.type === plugin.fullName) {
|
||||
currentStep.value.type = undefined;
|
||||
currentStep.value.title = "新任务";
|
||||
}
|
||||
const group = pluginGroup.value as PluginGroups;
|
||||
const groups = group.groups;
|
||||
if (pluginSearch.value.keyword) {
|
||||
const keyword = pluginSearch.value.keyword.toLowerCase();
|
||||
const list = groups.all.plugins.filter((plugin: any) => {
|
||||
return plugin.title?.toLowerCase().includes(keyword) || plugin.desc?.toLowerCase().includes(keyword) || plugin.name?.toLowerCase().includes(keyword);
|
||||
});
|
||||
return {
|
||||
search: { key: "search", title: "搜索结果", plugins: list },
|
||||
};
|
||||
} else {
|
||||
return groups;
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => {
|
||||
return pluginSearch.value.keyword;
|
||||
},
|
||||
(val: any) => {
|
||||
if (val) {
|
||||
pluginGroupActive.value = "search";
|
||||
} else {
|
||||
pluginGroupActive.value = "all";
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
pluginGroupActive,
|
||||
computedPluginGroups,
|
||||
pluginSearch,
|
||||
pluginSourceActive,
|
||||
stepTypeSelected,
|
||||
handlePluginConfirm,
|
||||
handleOnlinePluginUninstalled,
|
||||
stepTypeSave,
|
||||
stepFormRef,
|
||||
mode,
|
||||
@@ -436,31 +372,27 @@ const labelCol = ref({ span: 6 });
|
||||
const wrapperCol = ref({ span: 16 });
|
||||
|
||||
const stepFormRes = useStepForm();
|
||||
const { pluginGroupActive, computedPluginGroups, pluginSearch, stepTypeSelected, stepTypeSave, stepFormRef, stepDrawerVisible, currentStep, currentPlugin, stepSave, stepDelete, getScopeFunc, fullscreen } = stepFormRes;
|
||||
const {
|
||||
pluginSourceActive,
|
||||
stepTypeSelected,
|
||||
handlePluginConfirm,
|
||||
handleOnlinePluginUninstalled,
|
||||
stepTypeSave,
|
||||
stepFormRef,
|
||||
stepDrawerVisible,
|
||||
currentStep,
|
||||
currentPlugin,
|
||||
stepSave,
|
||||
stepDelete,
|
||||
getScopeFunc,
|
||||
fullscreen,
|
||||
} = stepFormRes;
|
||||
defineExpose({
|
||||
...stepFormRes,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.cd-step-form-tab-label {
|
||||
// 包括dropdown
|
||||
display: flex;
|
||||
align-items: center;
|
||||
//width: 120px;
|
||||
.fs-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #00b7ff;
|
||||
|
||||
svg {
|
||||
vertical-align: middle !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-form-drawer {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -514,6 +446,10 @@ defineExpose({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
font-size: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.body {
|
||||
@@ -542,7 +478,9 @@ defineExpose({
|
||||
.ant-card-meta-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.ant-avatar {
|
||||
@@ -558,19 +496,21 @@ defineExpose({
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 14px;
|
||||
height: 100px;
|
||||
min-height: 100px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
overflow-y: hidden;
|
||||
|
||||
.ant-card-meta-description {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
color: #7f7f7f;
|
||||
}
|
||||
}
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="step-plugin-source-pane-local">
|
||||
<div class="step-plugin-search">
|
||||
<a-input-search v-model:value="localPluginSearch.keyword" placeholder="搜索本地插件" :allow-clear="true" :show-search="true"></a-input-search>
|
||||
</div>
|
||||
<a-tabs v-model:active-key="pluginGroupActive" tab-position="left" class="flex-1 overflow-hidden h-full">
|
||||
<a-tab-pane v-for="group of computedLocalPluginGroups" :key="group.key" class="scroll-y">
|
||||
<template #tab>
|
||||
<div class="cd-step-form-tab-label" @click="handleLocalPluginGroupChange(group.key)">
|
||||
<fs-icon :icon="group.icon" class="mr-2" />
|
||||
<div>{{ group.title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!group.plugins || group.plugins.length === 0" class="step-plugin-empty">没有找到插件</div>
|
||||
<a-row v-else :gutter="10">
|
||||
<a-col v-for="item of group.plugins" :key="item.key || item.name" class="step-plugin w-full md:w-[50%]">
|
||||
<a-card hoverable :class="{ current: item.name === selectedType }" @click="emit('select', item)" @dblclick="emit('confirm', item)">
|
||||
<a-card-meta>
|
||||
<template #title>
|
||||
<fs-icon class="plugin-icon" :icon="item.icon || 'clarity:plugin-line'"></fs-icon>
|
||||
<span class="title" :title="item.title">{{ item.title }}</span>
|
||||
<vip-button v-if="item.needPlus" mode="icon" />
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="plugin-card-desc" :title="item.desc" v-html="transformDesc(item.desc)"></div>
|
||||
</template>
|
||||
</a-card-meta>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, Ref, watch } from "vue";
|
||||
import { PluginGroups, usePluginStore } from "/@/store/plugin";
|
||||
import { useUserStore } from "/@/store/user";
|
||||
import { utils } from "/@/utils";
|
||||
|
||||
defineProps({
|
||||
selectedType: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select", plugin: any): void;
|
||||
(e: "confirm", plugin: any): void;
|
||||
}>();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const pluginStore = usePluginStore();
|
||||
const pluginGroupActive = ref("all");
|
||||
const pluginGroup: Ref<PluginGroups | undefined> = ref();
|
||||
const localPluginSearch = ref({
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
function transformDesc(desc: string = "") {
|
||||
return utils.transformLink(desc);
|
||||
}
|
||||
|
||||
function matchKeyword(plugin: any, keyword: string) {
|
||||
const fields = [plugin.title, plugin.desc, plugin.name, plugin.fullName, plugin.author, plugin.latest];
|
||||
return fields.some(field =>
|
||||
String(field || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadPluginGroups() {
|
||||
pluginGroup.value = await pluginStore.getGroups();
|
||||
}
|
||||
|
||||
function handleLocalPluginGroupChange(groupKey: string) {
|
||||
pluginGroupActive.value = groupKey;
|
||||
localPluginSearch.value.keyword = "";
|
||||
}
|
||||
|
||||
const computedLocalPluginGroups: any = computed(() => {
|
||||
if (!pluginGroup.value) {
|
||||
return {};
|
||||
}
|
||||
const groups = pluginGroup.value.groups;
|
||||
const keyword = localPluginSearch.value.keyword.trim().toLowerCase();
|
||||
const visibleGroups: any = {};
|
||||
for (const groupKey of Object.keys(groups)) {
|
||||
if (groupKey === "admin" && !userStore.isAdmin) {
|
||||
continue;
|
||||
}
|
||||
visibleGroups[groupKey] = groups[groupKey];
|
||||
}
|
||||
if (!keyword) {
|
||||
return visibleGroups;
|
||||
}
|
||||
const filteredGroups: any = {};
|
||||
for (const groupKey of Object.keys(visibleGroups)) {
|
||||
const currentGroup = visibleGroups[groupKey];
|
||||
filteredGroups[groupKey] = {
|
||||
...currentGroup,
|
||||
plugins: currentGroup.plugins.filter((plugin: any) => {
|
||||
return matchKeyword(plugin, keyword);
|
||||
}),
|
||||
};
|
||||
}
|
||||
return filteredGroups;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => {
|
||||
return localPluginSearch.value.keyword;
|
||||
},
|
||||
val => {
|
||||
if (val) {
|
||||
pluginGroupActive.value = "all";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
loadPluginGroups();
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.step-plugin-source-pane-local {
|
||||
.cd-step-form-tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.fs-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #00b7ff;
|
||||
|
||||
svg {
|
||||
vertical-align: middle !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-plugin-source-pane {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step-plugin-search {
|
||||
flex: none;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.step-plugin-empty {
|
||||
display: flex;
|
||||
min-height: 320px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8794;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.plugin-card-desc {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<div class="step-plugin-source-pane-online">
|
||||
<div class="step-plugin-search">
|
||||
<a-input-search v-model:value="onlinePluginSearch.keyword" placeholder="搜索插件市场" :allow-clear="true" :show-search="true" :loading="onlineLoading" @search="handleOnlinePluginSearch"></a-input-search>
|
||||
</div>
|
||||
<a-tabs v-model:active-key="onlinePluginGroupActive" tab-position="left" class="flex-1 overflow-hidden h-full step-market-tabs">
|
||||
<a-tab-pane v-for="group of computedOnlinePluginGroups" :key="group.key" class="scroll-y">
|
||||
<template #tab>
|
||||
<div class="cd-step-form-tab-label" @click="handleOnlinePluginGroupChange(group.key)">
|
||||
<fs-icon :icon="group.icon" class="mr-2" />
|
||||
<div>{{ group.title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="onlineLoading" class="step-market-state">正在加载插件市场...</div>
|
||||
<div v-else-if="!group.plugins || group.plugins.length === 0" class="step-plugin-empty">没有找到插件</div>
|
||||
<template v-else>
|
||||
<div class="step-market-content">
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col v-for="item of group.plugins" :key="item.key || item.fullName" class="step-plugin market-plugin-col w-full md:w-[50%]">
|
||||
<PluginItemCard
|
||||
:plugin="item"
|
||||
simple
|
||||
:current="isOnlinePluginCurrent(item)"
|
||||
:install-loading="isOnlineActionLoading(item, 'install')"
|
||||
:uninstall-loading="isOnlineActionLoading(item, 'uninstall')"
|
||||
:toggle-loading="isOnlineActionLoading(item, 'toggle')"
|
||||
@click="onlinePluginSelected(item)"
|
||||
@dblclick="handleOnlinePluginCardDblclick(item)"
|
||||
@install="installOnlineStepPlugin"
|
||||
@uninstall="uninstallOnlineStepPlugin"
|
||||
@toggle-disabled="toggleOnlineStepPluginDisabled"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div class="online-plugin-pagination">
|
||||
<a-pagination v-model:current="onlinePage" size="small" :page-size="onlinePageSize" :total="onlineCurrentTotal" :show-size-changer="false" simple />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import { computed, ref, Ref, watch } from "vue";
|
||||
import { PluginGroups, usePluginStore } from "/@/store/plugin";
|
||||
import { useUserStore } from "/@/store/user";
|
||||
import PluginItemCard from "/@/views/sys/plugin/components/plugin-item-card.vue";
|
||||
import * as pluginApi from "/@/views/sys/plugin/api";
|
||||
import { useI18n } from "/src/locales";
|
||||
|
||||
const props = defineProps({
|
||||
selectedType: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select", plugin: any): void;
|
||||
(e: "confirm", plugin: any): void;
|
||||
(e: "uninstalled", plugin: any): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const pluginStore = usePluginStore();
|
||||
const pluginGroup: Ref<PluginGroups | undefined> = ref();
|
||||
const onlinePlugins: Ref<pluginApi.OnlinePluginBean[]> = ref([]);
|
||||
const onlineGroupPlugins: Ref<pluginApi.OnlinePluginBean[]> = ref([]);
|
||||
const onlineLoading: Ref<boolean> = ref(false);
|
||||
const onlineActionLoading: Ref<string> = ref("");
|
||||
const onlineGroupLoaded: Ref<boolean> = ref(false);
|
||||
const onlinePluginSearch = ref({
|
||||
keyword: "",
|
||||
});
|
||||
const onlinePluginQueryKeyword = ref("");
|
||||
const onlinePluginGroupActive = ref("all");
|
||||
const onlinePage = ref(1);
|
||||
const onlinePageSize = 8;
|
||||
let onlineRequestId = 0;
|
||||
|
||||
async function loadPluginGroups() {
|
||||
pluginGroup.value = await pluginStore.getGroups();
|
||||
}
|
||||
|
||||
function getOnlinePluginListReq(req?: { groupKey?: string; keyword?: string }) {
|
||||
const groupKey = req?.groupKey ?? onlinePluginGroupActive.value;
|
||||
const keyword = (req?.keyword ?? onlinePluginQueryKeyword.value).trim();
|
||||
const query: { pluginType: string; group?: string; keyword?: string } = {
|
||||
pluginType: "deploy",
|
||||
};
|
||||
if (groupKey && groupKey !== "all") {
|
||||
query.group = groupKey;
|
||||
}
|
||||
if (keyword) {
|
||||
query.keyword = keyword;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
async function loadOnlinePluginGroups(force = false) {
|
||||
if (!userStore.isAdmin) {
|
||||
return [];
|
||||
}
|
||||
if (onlineGroupLoaded.value && !force) {
|
||||
return onlineGroupPlugins.value;
|
||||
}
|
||||
const list = await pluginApi.OnlinePluginList({ pluginType: "deploy" });
|
||||
onlineGroupPlugins.value = list;
|
||||
onlineGroupLoaded.value = true;
|
||||
return list;
|
||||
}
|
||||
|
||||
async function loadOnlinePlugins(force = false, options?: { silent?: boolean }) {
|
||||
if (!userStore.isAdmin) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++onlineRequestId;
|
||||
if (!options?.silent) {
|
||||
onlineLoading.value = true;
|
||||
}
|
||||
try {
|
||||
const groupPlugins = await loadOnlinePluginGroups(force);
|
||||
if (requestId !== onlineRequestId) {
|
||||
return;
|
||||
}
|
||||
const query = getOnlinePluginListReq();
|
||||
const isFullQuery = !query.group && !query.keyword;
|
||||
if (isFullQuery) {
|
||||
onlinePlugins.value = groupPlugins;
|
||||
} else {
|
||||
const list = await pluginApi.OnlinePluginList(query);
|
||||
if (requestId !== onlineRequestId) {
|
||||
return;
|
||||
}
|
||||
onlinePlugins.value = list;
|
||||
}
|
||||
} finally {
|
||||
if (!options?.silent && requestId === onlineRequestId) {
|
||||
onlineLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOnlinePluginDisplayName(plugin: pluginApi.OnlinePluginBean) {
|
||||
if (plugin.fullName) {
|
||||
return plugin.fullName;
|
||||
}
|
||||
if (plugin.author && plugin.name) {
|
||||
return `${plugin.author}/${plugin.name}`;
|
||||
}
|
||||
return plugin.name || "";
|
||||
}
|
||||
|
||||
function buildOnlinePluginCard(plugin: pluginApi.OnlinePluginBean) {
|
||||
const fullName = getOnlinePluginDisplayName(plugin);
|
||||
return {
|
||||
...plugin,
|
||||
key: `online:${fullName}`,
|
||||
name: fullName,
|
||||
fullName,
|
||||
title: plugin.title || plugin.name || fullName,
|
||||
desc: plugin.desc,
|
||||
icon: plugin.icon || "clarity:plugin-line",
|
||||
__online: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getOnlinePluginGroupKey(plugin: pluginApi.OnlinePluginBean) {
|
||||
return (plugin.group || "other").trim() || "other";
|
||||
}
|
||||
|
||||
function getOnlinePluginGroupMeta(groupKey: string) {
|
||||
const localGroups = pluginGroup.value?.groups || {};
|
||||
const localGroup = localGroups[groupKey];
|
||||
if (localGroup) {
|
||||
return {
|
||||
title: localGroup.title,
|
||||
icon: localGroup.icon,
|
||||
order: localGroup.order,
|
||||
};
|
||||
}
|
||||
if (groupKey === "other") {
|
||||
return {
|
||||
title: "其他",
|
||||
icon: "clarity:plugin-line",
|
||||
order: 9999,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: groupKey,
|
||||
icon: "clarity:plugin-line",
|
||||
order: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function getFilteredOnlinePlugins() {
|
||||
return onlinePlugins.value.map(plugin => buildOnlinePluginCard(plugin));
|
||||
}
|
||||
|
||||
function getOnlinePluginsByGroup(groupKey: string) {
|
||||
const list = getFilteredOnlinePlugins();
|
||||
if (groupKey === "all") {
|
||||
return list;
|
||||
}
|
||||
return list.filter(plugin => getOnlinePluginGroupKey(plugin) === groupKey);
|
||||
}
|
||||
|
||||
function getPagedOnlinePlugins(groupKey: string) {
|
||||
const list = getOnlinePluginsByGroup(groupKey);
|
||||
const start = (onlinePage.value - 1) * onlinePageSize;
|
||||
return list.slice(start, start + onlinePageSize);
|
||||
}
|
||||
|
||||
function getOnlinePluginActionKey(plugin: pluginApi.OnlinePluginBean, action: "install" | "uninstall" | "toggle") {
|
||||
if (!plugin.fullName) {
|
||||
return "";
|
||||
}
|
||||
return `${action}:${plugin.fullName}`;
|
||||
}
|
||||
|
||||
function isOnlineActionLoading(plugin: pluginApi.OnlinePluginBean, action: "install" | "uninstall" | "toggle") {
|
||||
return onlineActionLoading.value === getOnlinePluginActionKey(plugin, action);
|
||||
}
|
||||
|
||||
async function installOnlineStepPlugin(plugin: any) {
|
||||
if (!plugin.fullName) {
|
||||
return;
|
||||
}
|
||||
onlineActionLoading.value = getOnlinePluginActionKey(plugin, "install");
|
||||
try {
|
||||
await pluginApi.OnlinePluginInstall({
|
||||
fullName: plugin.fullName,
|
||||
version: plugin.latest,
|
||||
});
|
||||
message.success(t("certd.onlinePluginInstallSuccess"));
|
||||
await pluginStore.reload();
|
||||
await loadPluginGroups();
|
||||
await loadOnlinePlugins(true, { silent: true });
|
||||
} finally {
|
||||
onlineActionLoading.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallOnlineStepPlugin(plugin: any) {
|
||||
if (!plugin.localPluginId || !plugin.fullName) {
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t("certd.confirm"),
|
||||
content: t("certd.onlinePluginDeleteConfirm", { name: plugin.fullName }),
|
||||
async onOk() {
|
||||
onlineActionLoading.value = getOnlinePluginActionKey(plugin, "uninstall");
|
||||
try {
|
||||
await pluginApi.DelObj(plugin.localPluginId);
|
||||
message.success(t("certd.onlinePluginUninstallSuccess"));
|
||||
if (props.selectedType === plugin.fullName) {
|
||||
emit("uninstalled", plugin);
|
||||
}
|
||||
await pluginStore.reload();
|
||||
await loadPluginGroups();
|
||||
await loadOnlinePlugins(true, { silent: true });
|
||||
} finally {
|
||||
onlineActionLoading.value = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOnlineStepPluginDisabled(plugin: any) {
|
||||
if (!plugin.localPluginId || !plugin.fullName) {
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t("certd.confirm"),
|
||||
content: `${t("certd.confirmToggle")} ${!plugin.localDisabled ? t("certd.disable") : t("certd.enable")}?`,
|
||||
async onOk() {
|
||||
onlineActionLoading.value = getOnlinePluginActionKey(plugin, "toggle");
|
||||
try {
|
||||
await pluginApi.SetDisabled({
|
||||
id: plugin.localPluginId,
|
||||
name: plugin.name,
|
||||
type: "store",
|
||||
disabled: !plugin.localDisabled,
|
||||
});
|
||||
message.success(t("certd.operationSuccess"));
|
||||
await pluginStore.reload();
|
||||
await loadPluginGroups();
|
||||
await loadOnlinePlugins(true, { silent: true });
|
||||
} finally {
|
||||
onlineActionLoading.value = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getInstalledPlugin(plugin: any) {
|
||||
if (!plugin.installed || !plugin.fullName) {
|
||||
return null;
|
||||
}
|
||||
const groups = pluginGroup.value;
|
||||
if (!groups) {
|
||||
return null;
|
||||
}
|
||||
const candidateNames = [plugin.fullName, plugin.name, plugin.author && plugin.name ? `${plugin.author}/${plugin.name}` : ""].filter(Boolean);
|
||||
for (const name of candidateNames) {
|
||||
const installedPlugin = groups.get(name);
|
||||
if (installedPlugin) {
|
||||
return installedPlugin;
|
||||
}
|
||||
}
|
||||
return groups.groups.all.plugins.find((item: any) => {
|
||||
if (plugin.localPluginId && item.id === plugin.localPluginId) {
|
||||
return true;
|
||||
}
|
||||
return item.author === plugin.author && item.name === plugin.name;
|
||||
});
|
||||
}
|
||||
|
||||
function isOnlinePluginCurrent(plugin: any) {
|
||||
const installedPlugin = getInstalledPlugin(plugin);
|
||||
return !!installedPlugin && installedPlugin.name === props.selectedType;
|
||||
}
|
||||
|
||||
function onlinePluginSelected(plugin: any) {
|
||||
const installedPlugin = getInstalledPlugin(plugin);
|
||||
if (!installedPlugin) {
|
||||
return;
|
||||
}
|
||||
emit("select", installedPlugin);
|
||||
}
|
||||
|
||||
function handleOnlinePluginCardDblclick(plugin: any) {
|
||||
const installedPlugin = getInstalledPlugin(plugin);
|
||||
if (!installedPlugin) {
|
||||
return;
|
||||
}
|
||||
emit("confirm", installedPlugin);
|
||||
}
|
||||
|
||||
function handleOnlinePluginGroupChange(groupKey: string) {
|
||||
onlinePluginGroupActive.value = groupKey;
|
||||
onlinePluginSearch.value.keyword = "";
|
||||
onlinePluginQueryKeyword.value = "";
|
||||
onlinePage.value = 1;
|
||||
loadOnlinePlugins();
|
||||
}
|
||||
|
||||
function handleOnlinePluginSearch(value: string) {
|
||||
onlinePluginQueryKeyword.value = (value || "").trim();
|
||||
onlinePluginGroupActive.value = "all";
|
||||
onlinePage.value = 1;
|
||||
loadOnlinePlugins();
|
||||
}
|
||||
|
||||
const computedOnlinePluginGroups: any = computed(() => {
|
||||
const allPlugins = onlineGroupPlugins.value.map(plugin => buildOnlinePluginCard(plugin));
|
||||
const groups: any = {
|
||||
all: {
|
||||
key: "all",
|
||||
title: t("certd.all"),
|
||||
order: 0,
|
||||
icon: "material-symbols:border-all-rounded",
|
||||
plugins: getPagedOnlinePlugins("all"),
|
||||
},
|
||||
};
|
||||
const groupKeys = Array.from(new Set(allPlugins.map(plugin => getOnlinePluginGroupKey(plugin))));
|
||||
groupKeys.sort((left, right) => {
|
||||
const leftMeta = getOnlinePluginGroupMeta(left);
|
||||
const rightMeta = getOnlinePluginGroupMeta(right);
|
||||
return leftMeta.order - rightMeta.order || left.localeCompare(right);
|
||||
});
|
||||
for (const groupKey of groupKeys) {
|
||||
const meta = getOnlinePluginGroupMeta(groupKey);
|
||||
groups[groupKey] = {
|
||||
key: groupKey,
|
||||
title: meta.title,
|
||||
order: meta.order,
|
||||
icon: meta.icon,
|
||||
plugins: getPagedOnlinePlugins(groupKey),
|
||||
};
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const onlineTotalPage = computed(() => {
|
||||
const total = getOnlinePluginsByGroup(onlinePluginGroupActive.value).length;
|
||||
return Math.max(1, Math.ceil(total / onlinePageSize));
|
||||
});
|
||||
|
||||
const onlineCurrentTotal = computed(() => {
|
||||
return getOnlinePluginsByGroup(onlinePluginGroupActive.value).length;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => {
|
||||
return onlinePluginGroupActive.value;
|
||||
},
|
||||
() => {
|
||||
onlinePage.value = 1;
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => {
|
||||
return onlineTotalPage.value;
|
||||
},
|
||||
totalPage => {
|
||||
if (onlinePage.value > totalPage) {
|
||||
onlinePage.value = totalPage;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
loadPluginGroups();
|
||||
loadOnlinePlugins();
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.step-plugin-source-pane-online {
|
||||
.step-market-content {
|
||||
min-height: 100%;
|
||||
padding: 2px 2px 12px 0;
|
||||
}
|
||||
|
||||
.step-market-state {
|
||||
display: flex;
|
||||
min-height: 320px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8794;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.market-plugin-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.online-plugin-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 42px;
|
||||
padding: 10px 2px 0;
|
||||
color: #5f6b7a;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.ant-pagination {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-pagination-simple-pager {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -82,6 +82,51 @@ export async function ImportPlugin(body: any) {
|
||||
});
|
||||
}
|
||||
|
||||
export type OnlinePluginBean = {
|
||||
id?: number;
|
||||
appId?: number;
|
||||
author?: string;
|
||||
pluginType?: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
group?: string;
|
||||
desc?: string;
|
||||
latest?: string;
|
||||
status?: string;
|
||||
downloadCount?: number;
|
||||
installed?: boolean;
|
||||
installedVersion?: string;
|
||||
upgradeAvailable?: boolean;
|
||||
localPluginId?: number;
|
||||
localDisabled?: boolean;
|
||||
syncTime?: number;
|
||||
};
|
||||
|
||||
export async function OnlinePluginList(body: { pluginType?: string; group?: string; keyword?: string }): Promise<OnlinePluginBean[]> {
|
||||
return await request({
|
||||
url: apiPrefix + "/online/list",
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function OnlinePluginSync(): Promise<OnlinePluginBean[]> {
|
||||
return await request({
|
||||
url: apiPrefix + "/online/sync",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
export async function OnlinePluginInstall(body: { fullName: string; version?: string }) {
|
||||
return await request({
|
||||
url: apiPrefix + "/online/install",
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export type PluginConfigBean = {
|
||||
name: string;
|
||||
disabled: boolean;
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<a-card
|
||||
hoverable
|
||||
class="plugin-card plugin-item-card"
|
||||
:class="{
|
||||
current,
|
||||
'is-simple': simple,
|
||||
'is-local': source === 'local',
|
||||
'is-installed': isInstalled,
|
||||
'is-disabled': isDisabled,
|
||||
}"
|
||||
@click="emit('click', plugin)"
|
||||
@dblclick="emit('dblclick', plugin)"
|
||||
>
|
||||
<div class="plugin-card__head">
|
||||
<div class="plugin-card__main">
|
||||
<fs-icon class="plugin-icon plugin-card__icon" :icon="plugin.icon || 'clarity:plugin-line'" />
|
||||
<div class="plugin-card__title-wrap">
|
||||
<router-link v-if="source === 'local' && plugin.type === 'custom'" class="plugin-card__title" :title="plugin.title || plugin.name" :to="`/sys/plugin/edit?id=${plugin.id}`" @click.stop>
|
||||
{{ plugin.title || plugin.name }}
|
||||
</router-link>
|
||||
<span v-else class="plugin-card__title" :title="plugin.title || plugin.name">{{ plugin.title || plugin.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-card__actions">
|
||||
<template v-if="source === 'local'">
|
||||
<div class="plugin-card__tools">
|
||||
<a-tooltip v-if="plugin.type === 'custom'" title="编辑">
|
||||
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="emit('edit', plugin)">
|
||||
<template #icon>
|
||||
<fs-icon icon="ion:create-outline" />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="plugin.type === 'custom'" title="复制">
|
||||
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="emit('copy', plugin)">
|
||||
<template #icon>
|
||||
<fs-icon icon="ion:copy-outline" />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="plugin.type === 'custom'" :title="t('certd.export')">
|
||||
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="emit('export-plugin', plugin)">
|
||||
<template #icon>
|
||||
<fs-icon icon="ion:cloud-download-outline" />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="showConfig" title="配置">
|
||||
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="emit('config', plugin)">
|
||||
<template #icon>
|
||||
<fs-icon icon="ion:settings-outline" />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div v-if="canRemoveLocal" class="plugin-card__action-zone plugin-card__local-action-zone">
|
||||
<a-tag color="green" class="plugin-status-tag">{{ t("certd.onlinePluginInstalled") }}</a-tag>
|
||||
<a-button class="plugin-card__action-button" size="small" danger ghost @click.stop="emit('remove', plugin)">
|
||||
{{ t("certd.onlinePluginUninstall") }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="plugin.installed">
|
||||
<div v-if="plugin.localPluginId" class="plugin-card__action-zone" :class="{ 'is-loading': uninstallLoading }">
|
||||
<a-tag color="green" class="plugin-status-tag">{{ t("certd.onlinePluginInstalled") }}</a-tag>
|
||||
<a-button class="plugin-card__action-button" size="small" danger ghost :loading="uninstallLoading" @click.stop="emit('uninstall', plugin)">
|
||||
{{ t("certd.onlinePluginUninstall") }}
|
||||
</a-button>
|
||||
</div>
|
||||
<a-tag v-else color="green" class="plugin-status-tag">{{ t("certd.onlinePluginInstalled") }}</a-tag>
|
||||
</template>
|
||||
<div v-else class="plugin-card__action-zone plugin-card__install-zone" :class="{ 'is-loading': installLoading }">
|
||||
<a-button class="plugin-card__action-button" size="small" type="primary" :loading="installLoading" @click.stop="emit('install', plugin)">
|
||||
{{ t("certd.onlinePluginInstall") }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plugin-card__desc" :title="plugin.desc">
|
||||
{{ plugin.desc || "暂无描述" }}
|
||||
</div>
|
||||
|
||||
<div class="plugin-card__meta">
|
||||
<template v-if="source === 'local'">
|
||||
<a-tooltip :title="toggleTitle">
|
||||
<a-tag class="plugin-status-tag" :color="plugin.disabled ? 'default' : 'green'" @click.stop="emit('toggle-disabled', plugin)">
|
||||
{{ plugin.disabled ? t("certd.onlinePluginDisabled") : t("certd.onlinePluginEnabled") }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
<a-tag>{{ pluginTypeLabel }}</a-tag>
|
||||
<a-tag>{{ sourceLabel }}</a-tag>
|
||||
<a-tag v-if="plugin.group">{{ plugin.group }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="!simple">
|
||||
<a-tooltip v-if="plugin.installed && plugin.localPluginId" :title="toggleTitle">
|
||||
<a-tag class="plugin-status-tag" :class="{ 'is-loading': toggleLoading }" :color="plugin.localDisabled ? 'default' : 'green'" @click.stop="emit('toggle-disabled', plugin)">
|
||||
{{ plugin.localDisabled ? t("certd.onlinePluginDisabled") : t("certd.onlinePluginEnabled") }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
<a-tag>{{ pluginTypeLabel }}</a-tag>
|
||||
<a-tag v-if="plugin.group">{{ plugin.group }}</a-tag>
|
||||
<a-tooltip :title="t('certd.onlinePluginDownloadCount', { count: plugin.downloadCount || 0 })">
|
||||
<a-tag class="plugin-card__download-count">
|
||||
<fs-icon icon="ion:cloud-download-outline" />
|
||||
<span>{{ formatDownloadCount(plugin.downloadCount || 0) }}</span>
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-tooltip :title="versionTitle">
|
||||
<span class="plugin-card__version" :class="{ 'is-upgradable': plugin.upgradeAvailable }" @click.stop="handleVersionClick">
|
||||
v{{ currentVersion }}
|
||||
<fs-icon v-if="plugin.upgradeAvailable" class="plugin-card__version-icon" icon="carbon:upgrade" />
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span v-if="source !== 'local' && authorName" class="plugin-card__author" :title="`${t('certd.author')}:${authorName}`">
|
||||
<fs-icon icon="ion:person-circle-outline" />
|
||||
<span>{{ authorName }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "/src/locales";
|
||||
|
||||
defineOptions({
|
||||
name: "PluginItemCard",
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
plugin: any;
|
||||
source?: "local" | "market";
|
||||
current?: boolean;
|
||||
simple?: boolean;
|
||||
showConfig?: boolean;
|
||||
installLoading?: boolean;
|
||||
uninstallLoading?: boolean;
|
||||
toggleLoading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
source: "market",
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "click", plugin: any): void;
|
||||
(e: "dblclick", plugin: any): void;
|
||||
(e: "edit", plugin: any): void;
|
||||
(e: "copy", plugin: any): void;
|
||||
(e: "export-plugin", plugin: any): void;
|
||||
(e: "config", plugin: any): void;
|
||||
(e: "remove", plugin: any): void;
|
||||
(e: "install", plugin: any): void;
|
||||
(e: "uninstall", plugin: any): void;
|
||||
(e: "toggle-disabled", plugin: any): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isInstalled = computed(() => {
|
||||
return props.source !== "local" && props.plugin.installed;
|
||||
});
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return props.source === "local" ? props.plugin.disabled : props.plugin.localDisabled;
|
||||
});
|
||||
|
||||
const canRemoveLocal = computed(() => {
|
||||
return props.plugin.type === "custom" || props.plugin.type === "store";
|
||||
});
|
||||
|
||||
const fullName = computed(() => {
|
||||
if (props.plugin.fullName) {
|
||||
return props.plugin.fullName;
|
||||
}
|
||||
if (props.plugin.author && props.plugin.name) {
|
||||
return `${props.plugin.author}/${props.plugin.name}`;
|
||||
}
|
||||
return props.plugin.name || "";
|
||||
});
|
||||
|
||||
const authorName = computed(() => {
|
||||
if (props.plugin.author) {
|
||||
return props.plugin.author;
|
||||
}
|
||||
const [author] = fullName.value.split("/");
|
||||
return author || "";
|
||||
});
|
||||
|
||||
const pluginTypeLabel = computed(() => {
|
||||
const labelMap: Record<string, string> = {
|
||||
access: t("certd.auth"),
|
||||
dnsProvider: t("certd.dns"),
|
||||
deploy: t("certd.deployPlugin"),
|
||||
};
|
||||
return labelMap[props.plugin.pluginType || ""] || props.plugin.pluginType || "-";
|
||||
});
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
const labelMap: Record<string, string> = {
|
||||
builtIn: t("certd.builtIn"),
|
||||
custom: t("certd.custom"),
|
||||
store: t("certd.store"),
|
||||
};
|
||||
return labelMap[props.plugin.type || ""] || props.plugin.type || "-";
|
||||
});
|
||||
|
||||
const currentVersion = computed(() => {
|
||||
if (props.source === "local") {
|
||||
return props.plugin.version || props.plugin.latest || "-";
|
||||
}
|
||||
return props.plugin.installedVersion || props.plugin.latest || "-";
|
||||
});
|
||||
|
||||
const versionTitle = computed(() => {
|
||||
if (props.source === "local") {
|
||||
return `${t("certd.version")} v${currentVersion.value}`;
|
||||
}
|
||||
if (props.plugin.installedVersion && props.plugin.upgradeAvailable && props.plugin.latest) {
|
||||
return `${t("certd.dashboard.latestVersion", { version: `v${props.plugin.latest}` })},${t("certd.onlinePluginClickToUpdate")}`;
|
||||
}
|
||||
if (props.plugin.installedVersion) {
|
||||
return t("certd.onlinePluginAlreadyLatest");
|
||||
}
|
||||
if (props.plugin.latest) {
|
||||
return t("certd.dashboard.latestVersion", { version: `v${props.plugin.latest}` });
|
||||
}
|
||||
return `${t("certd.version")} -`;
|
||||
});
|
||||
|
||||
const toggleTitle = computed(() => {
|
||||
const disabled = props.source === "local" ? props.plugin.disabled : props.plugin.localDisabled;
|
||||
return disabled ? t("certd.onlinePluginClickToEnable") : t("certd.onlinePluginClickToDisable");
|
||||
});
|
||||
|
||||
function formatDownloadCount(count: number) {
|
||||
if (count >= 10000) {
|
||||
return `${(count / 10000).toFixed(1).replace(/\.0$/, "")}w`;
|
||||
}
|
||||
return `${count}`;
|
||||
}
|
||||
|
||||
function handleVersionClick() {
|
||||
if (props.source === "local" || !props.plugin.upgradeAvailable) {
|
||||
return;
|
||||
}
|
||||
emit("install", props.plugin);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.plugin-item-card.plugin-card {
|
||||
width: 100%;
|
||||
border-color: #e5eaf1;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
transition-duration: 160ms;
|
||||
transition-property: border-color, box-shadow, transform, opacity;
|
||||
transition-timing-function: ease-out;
|
||||
|
||||
&:hover {
|
||||
border-color: #7dc4ff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.current {
|
||||
border-color: #00b7ff;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(0, 183, 255, 0.1),
|
||||
0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
&.is-installed {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fffb 100%);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.plugin-status-tag {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
margin: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
display: flex;
|
||||
min-height: 148px;
|
||||
flex-direction: column;
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
|
||||
.plugin-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-card__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.plugin-card__icon {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.plugin-card__main .plugin-icon {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
color: #00b7ff;
|
||||
font-size: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.plugin-card__title-wrap {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.plugin-card__title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a.plugin-card__title:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.plugin-card__author {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
.fs-icon {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__actions {
|
||||
display: flex;
|
||||
min-width: max-content;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.plugin-card__tools {
|
||||
display: flex;
|
||||
min-width: max-content;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
opacity: 0.76;
|
||||
transition-duration: 160ms;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.plugin-card__tool {
|
||||
display: inline-flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
color: #5f6b7a;
|
||||
opacity: 0.68;
|
||||
transition-duration: 160ms;
|
||||
transition-property: color, background-color, opacity, transform;
|
||||
transition-timing-function: ease-out;
|
||||
|
||||
.fs-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
opacity: 1;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__action-zone {
|
||||
position: relative;
|
||||
width: 68px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
|
||||
.plugin-status-tag,
|
||||
.plugin-card__action-button {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 26px;
|
||||
transition-duration: 160ms;
|
||||
transition-property: opacity, transform, filter;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.plugin-status-tag {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.plugin-card__action-button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(4px) scale(0.96);
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
&.is-loading,
|
||||
&:hover {
|
||||
.plugin-status-tag {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.96);
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.plugin-card__action-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__local-action-zone {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.plugin-card__install-zone {
|
||||
width: 68px;
|
||||
|
||||
.plugin-card__action-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.plugin-card__tools {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__desc {
|
||||
display: -webkit-box;
|
||||
min-height: 40px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
color: #5f6b7a;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-wrap: pretty;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.plugin-card__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
|
||||
.ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__version {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 22px;
|
||||
|
||||
&.is-upgradable {
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #0958d9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__version-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.plugin-card__download-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #6b7280;
|
||||
|
||||
.fs-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-simple {
|
||||
.plugin-card__desc {
|
||||
min-height: 40px;
|
||||
margin-bottom: 4px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.plugin-card__meta {
|
||||
min-height: 24px;
|
||||
margin-top: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-card__version {
|
||||
min-height: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.plugin-item-card.plugin-card {
|
||||
border-color: #303030;
|
||||
background: #1f1f1f;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&:hover {
|
||||
border-color: #1668dc;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
&.current {
|
||||
border-color: #1677ff;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(22, 119, 255, 0.18),
|
||||
0 8px 24px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
&.is-installed {
|
||||
background: linear-gradient(180deg, #1f1f1f 0%, #18251e 100%);
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plugin-card__title {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
a.plugin-card__title:hover {
|
||||
color: #69b1ff;
|
||||
}
|
||||
|
||||
.plugin-card__author,
|
||||
.plugin-card__desc,
|
||||
.plugin-card__version,
|
||||
.plugin-card__download-count {
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
|
||||
.plugin-card__meta {
|
||||
border-top-color: #303030;
|
||||
}
|
||||
|
||||
.plugin-card__tool {
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
|
||||
&:hover {
|
||||
color: #69b1ff;
|
||||
background: rgba(22, 119, 255, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card__version.is-upgradable {
|
||||
color: #69b1ff;
|
||||
|
||||
&:hover {
|
||||
color: #91caff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="plugin-market-panel">
|
||||
<div class="plugin-market-search">
|
||||
<a-select v-model:value="onlineSearch.pluginType" class="plugin-market-search__type" allow-clear :disabled="!marketReady" :placeholder="t('certd.pluginType')" @change="handleOnlineSearch">
|
||||
<a-select-option value="deploy">{{ t("certd.deployPlugin") }}</a-select-option>
|
||||
<a-select-option value="access">{{ t("certd.auth") }}</a-select-option>
|
||||
<a-select-option value="dnsProvider">{{ t("certd.dns") }}</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="onlineSearch.keyword"
|
||||
class="plugin-market-search__keyword"
|
||||
:placeholder="t('certd.onlinePluginSearch')"
|
||||
allow-clear
|
||||
:disabled="!marketReady"
|
||||
:loading="onlineLoading"
|
||||
@search="handleOnlineSearch"
|
||||
/>
|
||||
<a-tooltip :title="syncButtonTitle">
|
||||
<a-button type="primary" :loading="syncLoading" @click="syncOnlinePlugins">
|
||||
<template #icon>
|
||||
<fs-icon icon="ion:sync-outline" />
|
||||
</template>
|
||||
{{ t("certd.onlinePluginSync") }}
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="onlineLoading" class="plugin-market-state">正在查询插件市场...</div>
|
||||
<div v-else-if="!marketReady" class="plugin-market-state plugin-market-sync-state">
|
||||
<fs-icon icon="ion:cloud-download-outline" />
|
||||
<div>{{ t("certd.onlinePluginSyncFirst") }}</div>
|
||||
<a-tooltip :title="syncButtonTitle">
|
||||
<a-button type="primary" :loading="syncLoading" @click="syncOnlinePlugins">
|
||||
{{ t("certd.onlinePluginSync") }}
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-empty v-else-if="pagedOnlinePlugins.length === 0" class="plugin-card-empty" />
|
||||
<template v-else>
|
||||
<div class="plugin-card-grid">
|
||||
<PluginItemCard
|
||||
v-for="item of pagedOnlinePlugins"
|
||||
:key="getOnlinePluginFullName(item) || item.id || item.name"
|
||||
:plugin="item"
|
||||
:install-loading="isOnlineActionLoading(item, 'install')"
|
||||
:uninstall-loading="isOnlineActionLoading(item, 'uninstall')"
|
||||
:toggle-loading="isOnlineActionLoading(item, 'toggle')"
|
||||
@install="installOnlinePlugin"
|
||||
@uninstall="uninstallOnlinePlugin"
|
||||
@toggle-disabled="toggleOnlinePluginDisabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="plugin-market-pagination">
|
||||
<a-pagination v-model:current="onlinePage" size="small" :page-size="onlinePageSize" :total="onlinePlugins.length" :show-size-changer="false" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import * as api from "../api";
|
||||
import PluginItemCard from "./plugin-item-card.vue";
|
||||
import { usePluginStore } from "/@/store/plugin";
|
||||
import { useI18n } from "/src/locales";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "changed"): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const pluginStore = usePluginStore();
|
||||
const onlinePlugins = ref<api.OnlinePluginBean[]>([]);
|
||||
const onlineLoading = ref(true);
|
||||
const syncLoading = ref(false);
|
||||
const onlineActionLoading = ref("");
|
||||
const onlineLoaded = ref(false);
|
||||
const hasSynced = ref(false);
|
||||
const onlinePage = ref(1);
|
||||
const onlinePageSize = 12;
|
||||
const onlineSearch = ref({
|
||||
pluginType: undefined as string | undefined,
|
||||
keyword: "",
|
||||
});
|
||||
const onlineQuery = ref({
|
||||
pluginType: undefined as string | undefined,
|
||||
keyword: "",
|
||||
});
|
||||
let onlineRequestId = 0;
|
||||
const ONLINE_PLUGIN_LAST_SYNC_TIME_KEY = "certd:plugin-market:last-sync-time";
|
||||
const lastSyncTime = ref(readLastSyncTime());
|
||||
|
||||
const pagedOnlinePlugins = computed(() => {
|
||||
const start = (onlinePage.value - 1) * onlinePageSize;
|
||||
return onlinePlugins.value.slice(start, start + onlinePageSize);
|
||||
});
|
||||
|
||||
const marketReady = computed(() => {
|
||||
return hasSynced.value || onlinePlugins.value.length > 0;
|
||||
});
|
||||
|
||||
const syncButtonTitle = computed(() => {
|
||||
if (!lastSyncTime.value) {
|
||||
return t("certd.onlinePluginNotSynced");
|
||||
}
|
||||
return t("certd.onlinePluginLastSyncTime", {
|
||||
time: formatSyncTime(lastSyncTime.value),
|
||||
});
|
||||
});
|
||||
|
||||
function readLastSyncTime() {
|
||||
const value = Number(localStorage.getItem(ONLINE_PLUGIN_LAST_SYNC_TIME_KEY) || 0);
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function saveLastSyncTime(time: number) {
|
||||
lastSyncTime.value = time;
|
||||
localStorage.setItem(ONLINE_PLUGIN_LAST_SYNC_TIME_KEY, `${time}`);
|
||||
}
|
||||
|
||||
function formatSyncTime(time: number) {
|
||||
return new Date(time).toLocaleString();
|
||||
}
|
||||
|
||||
function getOnlinePluginFullName(row: api.OnlinePluginBean) {
|
||||
if (row.fullName) {
|
||||
return row.fullName;
|
||||
}
|
||||
if (row.author && row.name) {
|
||||
return `${row.author}/${row.name}`;
|
||||
}
|
||||
return row.name || "";
|
||||
}
|
||||
|
||||
function getOnlinePluginActionKey(row: api.OnlinePluginBean, action: "install" | "uninstall" | "toggle") {
|
||||
const fullName = getOnlinePluginFullName(row);
|
||||
if (!fullName) {
|
||||
return "";
|
||||
}
|
||||
return `${action}:${fullName}`;
|
||||
}
|
||||
|
||||
function isOnlineActionLoading(row: api.OnlinePluginBean, action: "install" | "uninstall" | "toggle") {
|
||||
return onlineActionLoading.value === getOnlinePluginActionKey(row, action);
|
||||
}
|
||||
|
||||
async function loadOnlinePlugins(options?: { force?: boolean; silent?: boolean }) {
|
||||
if (onlineLoaded.value && !options?.force && !onlineQuery.value.keyword && !onlineQuery.value.pluginType) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++onlineRequestId;
|
||||
if (!options?.silent) {
|
||||
onlineLoading.value = true;
|
||||
}
|
||||
try {
|
||||
const list = await api.OnlinePluginList({
|
||||
pluginType: onlineQuery.value.pluginType,
|
||||
keyword: onlineQuery.value.keyword.trim() || undefined,
|
||||
});
|
||||
if (requestId !== onlineRequestId) {
|
||||
return;
|
||||
}
|
||||
onlinePlugins.value = list;
|
||||
if (list.length > 0) {
|
||||
hasSynced.value = true;
|
||||
}
|
||||
onlineLoaded.value = true;
|
||||
} finally {
|
||||
if (requestId === onlineRequestId && !options?.silent) {
|
||||
onlineLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOnlinePlugins() {
|
||||
syncLoading.value = true;
|
||||
try {
|
||||
onlineSearch.value.pluginType = undefined;
|
||||
onlineSearch.value.keyword = "";
|
||||
onlineQuery.value.pluginType = undefined;
|
||||
onlineQuery.value.keyword = "";
|
||||
onlinePage.value = 1;
|
||||
onlinePlugins.value = await api.OnlinePluginSync();
|
||||
saveLastSyncTime(Date.now());
|
||||
onlineLoaded.value = true;
|
||||
hasSynced.value = true;
|
||||
message.success(t("certd.onlinePluginSyncSuccess"));
|
||||
} finally {
|
||||
syncLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnlineSearch() {
|
||||
onlineQuery.value = {
|
||||
pluginType: onlineSearch.value.pluginType,
|
||||
keyword: onlineSearch.value.keyword.trim(),
|
||||
};
|
||||
onlinePage.value = 1;
|
||||
loadOnlinePlugins({ force: true });
|
||||
}
|
||||
|
||||
async function installOnlinePlugin(row: api.OnlinePluginBean) {
|
||||
const fullName = getOnlinePluginFullName(row);
|
||||
if (!fullName) {
|
||||
return;
|
||||
}
|
||||
onlineActionLoading.value = getOnlinePluginActionKey(row, "install");
|
||||
try {
|
||||
await api.OnlinePluginInstall({
|
||||
fullName,
|
||||
version: row.latest,
|
||||
});
|
||||
await pluginStore.reload();
|
||||
emit("changed");
|
||||
await loadOnlinePlugins({ force: true, silent: true });
|
||||
message.success(t("certd.onlinePluginInstallSuccess"));
|
||||
} finally {
|
||||
onlineActionLoading.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOnlinePluginDisabled(row: api.OnlinePluginBean) {
|
||||
const fullName = getOnlinePluginFullName(row);
|
||||
if (!row.localPluginId || !fullName) {
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t("certd.confirm"),
|
||||
content: `${t("certd.confirmToggle")} ${!row.localDisabled ? t("certd.disable") : t("certd.enable")}?`,
|
||||
async onOk() {
|
||||
onlineActionLoading.value = getOnlinePluginActionKey(row, "toggle");
|
||||
try {
|
||||
await api.SetDisabled({
|
||||
id: row.localPluginId,
|
||||
name: row.name,
|
||||
type: "store",
|
||||
disabled: !row.localDisabled,
|
||||
});
|
||||
await pluginStore.reload();
|
||||
emit("changed");
|
||||
await loadOnlinePlugins({ force: true, silent: true });
|
||||
message.success(t("certd.operationSuccess"));
|
||||
} finally {
|
||||
onlineActionLoading.value = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function uninstallOnlinePlugin(row: api.OnlinePluginBean) {
|
||||
const fullName = getOnlinePluginFullName(row);
|
||||
if (!row.localPluginId || !fullName) {
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t("certd.confirm"),
|
||||
content: t("certd.onlinePluginDeleteConfirm", { name: fullName }),
|
||||
async onOk() {
|
||||
onlineActionLoading.value = getOnlinePluginActionKey(row, "uninstall");
|
||||
try {
|
||||
await api.DelObj(row.localPluginId);
|
||||
await pluginStore.reload();
|
||||
emit("changed");
|
||||
await loadOnlinePlugins({ force: true, silent: true });
|
||||
message.success(t("certd.onlinePluginUninstallSuccess"));
|
||||
} finally {
|
||||
onlineActionLoading.value = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOnlinePlugins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.plugin-market-panel {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-market-search {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 10px 12px;
|
||||
}
|
||||
|
||||
.plugin-market-search__type {
|
||||
width: 148px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.plugin-market-search__keyword {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.plugin-market-state {
|
||||
display: flex;
|
||||
min-height: 260px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8794;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.plugin-market-sync-state {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
> .fs-icon {
|
||||
color: #1677ff;
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-pagination {
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: flex-end;
|
||||
padding: 0 10px 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -110,6 +110,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||
},
|
||||
},
|
||||
table: {
|
||||
show: false,
|
||||
rowKey: "name",
|
||||
remove: {
|
||||
afterRemove: async context => {
|
||||
@@ -136,7 +137,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||
remove: {
|
||||
order: 999,
|
||||
show: compute(({ row }) => {
|
||||
return row.type === "custom";
|
||||
return row.type === "custom" || row.type === "store";
|
||||
}),
|
||||
},
|
||||
export: {
|
||||
@@ -347,7 +348,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||
data: [
|
||||
{ label: t("certd.builtIn"), value: "builtIn" },
|
||||
{ label: t("certd.custom"), value: "custom" },
|
||||
{ label: t("certd.store"), value: "store" },
|
||||
{ label: t("certd.installedStorePlugin"), value: "store" },
|
||||
],
|
||||
}),
|
||||
column: {
|
||||
|
||||
@@ -1,18 +1,48 @@
|
||||
<template>
|
||||
<fs-page class="page-cert">
|
||||
<fs-page class="page-sys-plugin">
|
||||
<template #header>
|
||||
<div class="title">
|
||||
{{ t("certd.pluginManagement") }}
|
||||
<span class="sub">{{ t("certd.pluginBetaWarning") }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<fs-crud ref="crudRef" v-bind="crudBinding">
|
||||
<!-- <template #pagination-left>-->
|
||||
<!-- <a-tooltip :title="t('certd.batchDelete')">-->
|
||||
<!-- <fs-button icon="DeleteOutlined" @click="handleBatchDelete"></fs-button>-->
|
||||
<!-- </a-tooltip>-->
|
||||
<!-- </template>-->
|
||||
</fs-crud>
|
||||
<a-tabs v-model:active-key="activeTab" class="plugin-page-tabs">
|
||||
<a-tab-pane key="local">
|
||||
<template #tab>
|
||||
<span class="plugin-page-tab-label">
|
||||
<fs-icon icon="ion:cube-outline" />
|
||||
<span>{{ t("certd.localPlugin") }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<fs-crud ref="crudRef" class="plugin-card-crud" v-bind="crudBinding">
|
||||
<a-empty v-if="pluginList.length === 0" class="plugin-card-empty" />
|
||||
<div v-else class="plugin-card-grid">
|
||||
<PluginItemCard
|
||||
v-for="(item, index) of pluginList"
|
||||
:key="item.id || item.name"
|
||||
source="local"
|
||||
:plugin="item"
|
||||
:show-config="settingStore.isComm"
|
||||
@edit="openEditPage"
|
||||
@copy="openCopy({ index, row: item })"
|
||||
@export-plugin="exportPlugin"
|
||||
@config="openConfig"
|
||||
@remove="removePlugin({ index, row: item })"
|
||||
@toggle-disabled="toggleDisabled"
|
||||
/>
|
||||
</div>
|
||||
</fs-crud>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="market">
|
||||
<template #tab>
|
||||
<span class="plugin-page-tab-label">
|
||||
<fs-icon icon="ion:storefront-outline" />
|
||||
<span>{{ t("certd.pluginMarket") }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<PluginMarketPanel @changed="handleMarketPluginChanged" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</fs-page>
|
||||
</template>
|
||||
|
||||
@@ -20,38 +50,195 @@
|
||||
import { useFs } from "@fast-crud/fast-crud";
|
||||
import createCrudOptions from "./crud";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import { DeleteBatch } from "./api";
|
||||
import * as api from "./api";
|
||||
import PluginMarketPanel from "./components/plugin-market-panel.vue";
|
||||
import PluginItemCard from "./components/plugin-item-card.vue";
|
||||
import { useI18n } from "/src/locales";
|
||||
import { useMounted } from "/@/use/use-mounted";
|
||||
import { computed, ref } from "vue";
|
||||
import { usePluginStore } from "/@/store/plugin";
|
||||
import { usePluginConfig } from "./use-config";
|
||||
import { useSettingStore } from "/src/store/settings";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const { t } = useI18n();
|
||||
const pluginStore = usePluginStore();
|
||||
const settingStore = useSettingStore();
|
||||
const { openConfigDialog } = usePluginConfig();
|
||||
const router = useRouter();
|
||||
|
||||
defineOptions({
|
||||
name: "SysPlugin",
|
||||
});
|
||||
const { crudBinding, crudRef, crudExpose, context } = useFs({ createCrudOptions });
|
||||
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
|
||||
const activeTab = ref("local");
|
||||
|
||||
const selectedRowKeys = context.selectedRowKeys;
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRowKeys.value?.length > 0) {
|
||||
Modal.confirm({
|
||||
title: t("certd.pluginManagement"),
|
||||
content: t("certd.batchDeleteConfirm", { count: selectedRowKeys.value.length }),
|
||||
async onOk() {
|
||||
await DeleteBatch(selectedRowKeys.value);
|
||||
message.info(t("certd.deleteSuccess"));
|
||||
crudExpose.doRefresh();
|
||||
selectedRowKeys.value = [];
|
||||
},
|
||||
});
|
||||
} else {
|
||||
message.error(t("certd.selectRecordFirst"));
|
||||
const pluginList = computed(() => {
|
||||
return crudBinding.value?.data || [];
|
||||
});
|
||||
|
||||
function openEditPage(row: any) {
|
||||
router.push(`/sys/plugin/edit?id=${row.id}`);
|
||||
}
|
||||
|
||||
async function openCopy(opts: any) {
|
||||
await crudExpose.openCopy({
|
||||
row: {
|
||||
...opts.row,
|
||||
},
|
||||
index: opts.index,
|
||||
});
|
||||
}
|
||||
|
||||
async function removePlugin(opts: any) {
|
||||
await crudExpose.doRemove(opts);
|
||||
}
|
||||
|
||||
async function openConfig(row: any) {
|
||||
await openConfigDialog({
|
||||
row,
|
||||
crudExpose,
|
||||
});
|
||||
}
|
||||
|
||||
async function exportPlugin(row: any) {
|
||||
const content = await api.ExportPlugin(row.id);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${row.name}.yaml`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function toggleDisabled(row: any) {
|
||||
Modal.confirm({
|
||||
title: t("certd.confirm"),
|
||||
content: `${t("certd.confirmToggle")} ${!row.disabled ? t("certd.disable") : t("certd.enable")}?`,
|
||||
async onOk() {
|
||||
await api.SetDisabled({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
disabled: !row.disabled,
|
||||
});
|
||||
await pluginStore.reload();
|
||||
crudExpose.doRefresh();
|
||||
message.success(t("certd.operationSuccess"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleMarketPluginChanged() {
|
||||
crudExpose.doRefresh();
|
||||
}
|
||||
|
||||
// 页面打开后获取列表数据
|
||||
useMounted(async () => {
|
||||
await crudExpose.doRefresh();
|
||||
});
|
||||
</script>
|
||||
<style lang="less"></style>
|
||||
<style lang="less">
|
||||
.page-sys-plugin {
|
||||
.fs-page-content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-card-crud {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plugin-page-tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-nav {
|
||||
flex: none;
|
||||
margin: 4px 0 12px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
> .ant-tabs-content-holder {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
> .ant-tabs-content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
> .ant-tabs-tabpane {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
> .ant-tabs-tabpane-active {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-page-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
|
||||
.fs-icon {
|
||||
flex: none;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card-empty {
|
||||
padding: 72px 0;
|
||||
}
|
||||
|
||||
.plugin-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 4px 10px 12px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.page-sys-plugin {
|
||||
.plugin-card-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.page-sys-plugin {
|
||||
.plugin-card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-sys-plugin {
|
||||
.plugin-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,8 @@ ALTER TABLE "cd_audit_log" ADD COLUMN "scope" varchar(32) NOT NULL DEFAULT ('use
|
||||
CREATE INDEX "index_audit_log_scope" ON "cd_audit_log" ("scope");
|
||||
|
||||
ALTER TABLE "cd_audit_log" ADD COLUMN "success" boolean NOT NULL DEFAULT (1);
|
||||
-- 删除project_name字段
|
||||
ALTER TABLE "cd_audit_log" DROP COLUMN "project_name";
|
||||
|
||||
-- 审计日志表索引
|
||||
CREATE INDEX "index_audit_log_type" ON "cd_audit_log" ("type");
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE "pi_plugin_market_item"
|
||||
(
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"app_id" integer,
|
||||
"full_name" varchar(200) NOT NULL,
|
||||
"author" varchar(100),
|
||||
"name" varchar(100),
|
||||
"plugin_type" varchar(100),
|
||||
"title" varchar(200),
|
||||
"icon" varchar(200),
|
||||
"group" varchar(100),
|
||||
"desc" varchar(1000),
|
||||
"latest" varchar(100),
|
||||
"status" varchar(100),
|
||||
"download_count" integer,
|
||||
"sync_time" integer,
|
||||
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "index_plugin_market_item_full_name" ON "pi_plugin_market_item" ("full_name");
|
||||
CREATE INDEX "index_plugin_market_item_plugin_type" ON "pi_plugin_market_item" ("plugin_type");
|
||||
CREATE INDEX "index_plugin_market_item_group" ON "pi_plugin_market_item" ("group");
|
||||
CREATE INDEX "index_plugin_market_item_sync_time" ON "pi_plugin_market_item" ("sync_time");
|
||||
@@ -43,7 +43,6 @@ process.on("uncaughtException", error => {
|
||||
// setInterval(log, 200);
|
||||
// log()
|
||||
// }
|
||||
|
||||
// startHeapLog();
|
||||
|
||||
@Configuration({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
|
||||
import { merge } from "lodash-es";
|
||||
import { CrudController } from "@certd/lib-server";
|
||||
import { PluginImportReq, PluginService } from "../../../modules/plugin/service/plugin-service.js";
|
||||
import { OnlinePluginInstallReq, OnlinePluginListReq, PluginImportReq, PluginService } from "../../../modules/plugin/service/plugin-service.js";
|
||||
import { CommPluginConfig, PluginConfig, PluginConfigService } from "../../../modules/plugin/service/plugin-config-service.js";
|
||||
import { AuditType } from "../../../modules/sys/enterprise/service/audit-constants.js";
|
||||
/**
|
||||
@@ -115,6 +115,26 @@ export class PluginController extends CrudController<PluginService> {
|
||||
return this.ok(res);
|
||||
}
|
||||
|
||||
@Post("/online/list", { description: "sys:settings:view", summary: "查询在线插件" })
|
||||
async onlineList(@Body(ALL) body: OnlinePluginListReq) {
|
||||
const res = await this.service.onlinePluginList(body);
|
||||
return this.ok(res);
|
||||
}
|
||||
|
||||
@Post("/online/sync", { description: "sys:settings:edit", summary: "同步在线插件" })
|
||||
async onlineSync() {
|
||||
const res = await this.service.syncOnlinePluginList();
|
||||
await this.auditLog({ content: "同步了在线插件市场" });
|
||||
return this.ok(res);
|
||||
}
|
||||
|
||||
@Post("/online/install", { description: "sys:settings:edit", summary: "安装在线插件" })
|
||||
async onlineInstall(@Body(ALL) body: OnlinePluginInstallReq) {
|
||||
const res = await this.service.installOnlinePlugin(body);
|
||||
await this.auditLog({ content: `安装了在线插件「${body.fullName}」` });
|
||||
return this.ok(res);
|
||||
}
|
||||
|
||||
@Post("/export", { description: "sys:settings:edit", summary: "导出插件" })
|
||||
async export(@Body("id") id: number) {
|
||||
const res = await this.service.exportPlugin(id);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity("pi_plugin_market_item")
|
||||
export class PluginMarketItemEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: "app_id", comment: "平台应用ID", nullable: true })
|
||||
appId: number;
|
||||
|
||||
@Column({ name: "full_name", comment: "插件完整名称", length: 200 })
|
||||
fullName: string;
|
||||
|
||||
@Column({ name: "author", comment: "作者", length: 100, nullable: true })
|
||||
author: string;
|
||||
|
||||
@Column({ name: "name", comment: "插件名称", length: 100, nullable: true })
|
||||
name: string;
|
||||
|
||||
@Column({ name: "plugin_type", comment: "插件类型", length: 100, nullable: true })
|
||||
pluginType: string;
|
||||
|
||||
@Column({ name: "title", comment: "标题", length: 200, nullable: true })
|
||||
title: string;
|
||||
|
||||
@Column({ name: "icon", comment: "图标", length: 200, nullable: true })
|
||||
icon: string;
|
||||
|
||||
@Column({ name: "group", comment: "分组", length: 100, nullable: true })
|
||||
group: string;
|
||||
|
||||
@Column({ name: "desc", comment: "描述", length: 1000, nullable: true })
|
||||
desc: string;
|
||||
|
||||
@Column({ name: "latest", comment: "最新版本", length: 100, nullable: true })
|
||||
latest: string;
|
||||
|
||||
@Column({ name: "status", comment: "状态", length: 100, nullable: true })
|
||||
status: string;
|
||||
|
||||
@Column({ name: "download_count", comment: "下载次数", nullable: true })
|
||||
downloadCount: number;
|
||||
|
||||
@Column({ name: "sync_time", comment: "同步时间", nullable: true })
|
||||
syncTime: number;
|
||||
|
||||
@Column({
|
||||
name: "create_time",
|
||||
comment: "创建时间",
|
||||
default: () => "CURRENT_TIMESTAMP",
|
||||
})
|
||||
createTime: Date;
|
||||
|
||||
@Column({
|
||||
name: "update_time",
|
||||
comment: "修改时间",
|
||||
default: () => "CURRENT_TIMESTAMP",
|
||||
})
|
||||
updateTime: Date;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { PluginService, parseOnlinePluginFullName, isOnlinePluginUpgradeAvailable } from "./plugin-service.js";
|
||||
|
||||
function createMarketRepositoryMock(list: any[]) {
|
||||
return {
|
||||
createQueryBuilder() {
|
||||
return {
|
||||
andWhere() {
|
||||
return this;
|
||||
},
|
||||
orderBy() {
|
||||
return this;
|
||||
},
|
||||
addOrderBy() {
|
||||
return this;
|
||||
},
|
||||
getMany() {
|
||||
return list;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("PluginService online plugins", () => {
|
||||
it("parses online plugin full name", () => {
|
||||
assert.deepEqual(parseOnlinePluginFullName("greper/DeployToDemo"), {
|
||||
author: "greper",
|
||||
name: "DeployToDemo",
|
||||
});
|
||||
|
||||
assert.throws(() => parseOnlinePluginFullName("greper/deploy:DeployToDemo"), /插件标识格式错误/);
|
||||
assert.throws(() => parseOnlinePluginFullName("DeployToDemo"), /插件标识格式错误/);
|
||||
});
|
||||
|
||||
it("detects when an online plugin can upgrade the installed version", () => {
|
||||
assert.equal(isOnlinePluginUpgradeAvailable("1.0.0", "1.0.1"), true);
|
||||
assert.equal(isOnlinePluginUpgradeAvailable("v1.2.0", "1.2.0"), false);
|
||||
assert.equal(isOnlinePluginUpgradeAvailable("2.0.0", "1.9.9"), false);
|
||||
assert.equal(isOnlinePluginUpgradeAvailable("beta", "beta2"), true);
|
||||
});
|
||||
|
||||
it("marks online plugins with local installation state", async () => {
|
||||
const service = new PluginService();
|
||||
service.pluginMarketItemRepository = createMarketRepositoryMock([
|
||||
{
|
||||
fullName: "greper/DeployToDemo",
|
||||
author: "greper",
|
||||
name: "DeployToDemo",
|
||||
latest: "1.0.1",
|
||||
syncTime: 1784566000000,
|
||||
},
|
||||
]) as any;
|
||||
service.repository = {
|
||||
async findOne() {
|
||||
return {
|
||||
id: 3,
|
||||
version: "1.0.0",
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
|
||||
const list = await service.onlinePluginList({});
|
||||
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].installed, true);
|
||||
assert.equal(list[0].installedVersion, "1.0.0");
|
||||
assert.equal(list[0].upgradeAvailable, true);
|
||||
assert.equal(list[0].localPluginId, 3);
|
||||
});
|
||||
|
||||
it("matches installed online dnsProvider plugins saved with full name", async () => {
|
||||
const service = new PluginService();
|
||||
service.pluginMarketItemRepository = createMarketRepositoryMock([
|
||||
{
|
||||
fullName: "developer/testtest3",
|
||||
author: "developer",
|
||||
name: "testtest3",
|
||||
pluginType: "dnsProvider",
|
||||
latest: "1.0.0",
|
||||
},
|
||||
]) as any;
|
||||
service.repository = {
|
||||
async findOne(options: any) {
|
||||
const where = options.where;
|
||||
if (Array.isArray(where) && where.some(item => item.name === "developer/testtest3")) {
|
||||
return {
|
||||
id: 4,
|
||||
version: "1.0.0",
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as any;
|
||||
|
||||
const list = await service.onlinePluginList({});
|
||||
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].installed, true);
|
||||
assert.equal(list[0].upgradeAvailable, false);
|
||||
assert.equal(list[0].localPluginId, 4);
|
||||
});
|
||||
|
||||
it("matches legacy installed dnsProvider plugins saved without author", async () => {
|
||||
const service = new PluginService();
|
||||
service.pluginMarketItemRepository = createMarketRepositoryMock([
|
||||
{
|
||||
fullName: "developer/testtest3",
|
||||
author: "developer",
|
||||
name: "testtest3",
|
||||
pluginType: "dnsProvider",
|
||||
latest: "1.0.0",
|
||||
},
|
||||
]) as any;
|
||||
service.repository = {
|
||||
async findOne(options: any) {
|
||||
const where = options.where;
|
||||
if (Array.isArray(where) && where.some(item => item.name === "testtest3" && item.pluginType === "dnsProvider")) {
|
||||
return {
|
||||
id: 5,
|
||||
version: "1.0.0",
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as any;
|
||||
|
||||
const list = await service.onlinePluginList({});
|
||||
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].installed, true);
|
||||
assert.equal(list[0].upgradeAvailable, false);
|
||||
assert.equal(list[0].localPluginId, 5);
|
||||
});
|
||||
|
||||
it("does not mark uninstalled online plugins as upgradeable", async () => {
|
||||
const service = new PluginService();
|
||||
service.pluginMarketItemRepository = createMarketRepositoryMock([
|
||||
{
|
||||
fullName: "developer/new-plugin",
|
||||
author: "developer",
|
||||
name: "new-plugin",
|
||||
latest: "1.0.0",
|
||||
},
|
||||
]) as any;
|
||||
service.repository = {
|
||||
async findOne() {
|
||||
return null;
|
||||
},
|
||||
} as any;
|
||||
|
||||
const list = await service.onlinePluginList({});
|
||||
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].installed, false);
|
||||
assert.equal(list[0].upgradeAvailable, false);
|
||||
});
|
||||
|
||||
it("does not mark installed online plugins without local version as upgradeable", async () => {
|
||||
const service = new PluginService();
|
||||
service.pluginMarketItemRepository = createMarketRepositoryMock([
|
||||
{
|
||||
fullName: "developer/testtest2",
|
||||
author: "developer",
|
||||
name: "testtest2",
|
||||
pluginType: "access",
|
||||
latest: "1.0.0",
|
||||
},
|
||||
]) as any;
|
||||
service.repository = {
|
||||
async findOne() {
|
||||
return {
|
||||
id: 8,
|
||||
version: null,
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
|
||||
const list = await service.onlinePluginList({});
|
||||
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].installed, true);
|
||||
assert.equal(list[0].installedVersion, null);
|
||||
assert.equal(list[0].upgradeAvailable, false);
|
||||
});
|
||||
|
||||
it("syncs online plugin list metadata to local market table without yaml content", async () => {
|
||||
const service = new PluginService();
|
||||
const savedRecords: any[] = [];
|
||||
const existingRecords: any[] = [
|
||||
{
|
||||
id: 1,
|
||||
fullName: "developer/old-plugin",
|
||||
author: "developer",
|
||||
name: "old-plugin",
|
||||
pluginType: "deploy",
|
||||
},
|
||||
];
|
||||
const requestCalls: any[] = [];
|
||||
const buildItem = (index: number) => {
|
||||
return {
|
||||
fullName: `developer/plugin-${index}`,
|
||||
author: "developer",
|
||||
name: `plugin-${index}`,
|
||||
pluginType: index % 2 === 0 ? "deploy" : "access",
|
||||
title: `plugin ${index}`,
|
||||
latest: "1.0.0",
|
||||
status: "published",
|
||||
};
|
||||
};
|
||||
|
||||
service.plusService = {
|
||||
async register() {},
|
||||
async request(config: any) {
|
||||
requestCalls.push(config);
|
||||
assert.equal(config.url, "/activation/plugin/list");
|
||||
assert.equal(config.data.pluginType, undefined);
|
||||
assert.equal(config.data.page.limit, 200);
|
||||
if (config.data.page.start === 0) {
|
||||
return {
|
||||
list: Array.from({ length: 200 }, (_, index) => {
|
||||
return buildItem(index + 1);
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (config.data.page.start === 200) {
|
||||
return {
|
||||
list: [buildItem(201)],
|
||||
};
|
||||
}
|
||||
return {
|
||||
list: [],
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
service.pluginMarketItemRepository = {
|
||||
async find() {
|
||||
return existingRecords;
|
||||
},
|
||||
create(record: any) {
|
||||
return record;
|
||||
},
|
||||
async save(records: any[]) {
|
||||
savedRecords.push(...records);
|
||||
return records;
|
||||
},
|
||||
createQueryBuilder() {
|
||||
return {
|
||||
andWhere() {
|
||||
return this;
|
||||
},
|
||||
orderBy() {
|
||||
return this;
|
||||
},
|
||||
addOrderBy() {
|
||||
return this;
|
||||
},
|
||||
getMany() {
|
||||
return savedRecords;
|
||||
},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
service.repository = {
|
||||
async findOne() {
|
||||
return null;
|
||||
},
|
||||
} as any;
|
||||
|
||||
const res = await service.syncOnlinePluginList();
|
||||
|
||||
assert.equal(requestCalls.length, 2);
|
||||
assert.deepEqual(
|
||||
requestCalls.map(item => item.data.page.start),
|
||||
[0, 200]
|
||||
);
|
||||
assert.equal(res.length, 201);
|
||||
assert.equal(savedRecords.length, 201);
|
||||
assert.equal(
|
||||
savedRecords.some(record => record.content),
|
||||
false
|
||||
);
|
||||
assert.equal(savedRecords[0].fullName, "developer/plugin-1");
|
||||
assert.equal(savedRecords[200].fullName, "developer/plugin-201");
|
||||
});
|
||||
|
||||
it("downloads and imports online plugin content as store plugin", async () => {
|
||||
const service = new PluginService();
|
||||
let importReq: any;
|
||||
|
||||
service.plusService = {
|
||||
async register() {},
|
||||
async request(config: any) {
|
||||
assert.equal(config.url, "/activation/plugin/download");
|
||||
assert.deepEqual(config.data, { fullName: "greper/DeployToDemo", version: "1.0.1" });
|
||||
return {
|
||||
fullName: "greper/DeployToDemo",
|
||||
content: "name: DeployToDemo\npluginType: deploy\nauthor: greper\ncontent: |\n return class Demo {}\n",
|
||||
plugin: {
|
||||
fullName: "greper/DeployToDemo",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.1",
|
||||
},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
service.importPlugin = async req => {
|
||||
importReq = req;
|
||||
return { id: 9 };
|
||||
};
|
||||
|
||||
const res = await service.installOnlinePlugin({ fullName: "greper/DeployToDemo", version: "1.0.1" });
|
||||
|
||||
assert.equal(res.id, 9);
|
||||
assert.equal(res.fullName, "greper/DeployToDemo");
|
||||
assert.equal(importReq.override, true);
|
||||
assert.equal(importReq.type, "store");
|
||||
assert.match(importReq.content, /DeployToDemo/);
|
||||
});
|
||||
|
||||
it("fills missing yaml version from online plugin version before import", async () => {
|
||||
const service = new PluginService();
|
||||
let importReq: any;
|
||||
|
||||
service.plusService = {
|
||||
async register() {},
|
||||
async request() {
|
||||
return {
|
||||
fullName: "developer/testtest2",
|
||||
content: "name: testtest2\npluginType: access\nauthor: developer\ncontent: |\n return class DemoAccess {}\n",
|
||||
plugin: {
|
||||
fullName: "developer/testtest2",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
service.importPlugin = async req => {
|
||||
importReq = req;
|
||||
return { id: 10 };
|
||||
};
|
||||
|
||||
await service.installOnlinePlugin({ fullName: "developer/testtest2" });
|
||||
|
||||
assert.match(importReq.content, /version: 1\.0\.0/);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { addonRegistry, BaseService, PageReq } from "@certd/lib-server";
|
||||
import { addonRegistry, BaseService, PageReq, PlusService } from "@certd/lib-server";
|
||||
import { PluginEntity } from "../entity/plugin.js";
|
||||
import { PluginMarketItemEntity } from "../entity/plugin-market-item.js";
|
||||
import { InjectEntityModel } from "@midwayjs/typeorm";
|
||||
import { IsNull, Not, Repository } from "typeorm";
|
||||
import { Brackets, IsNull, Not, Repository } from "typeorm";
|
||||
import { isComm } from "@certd/plus-core";
|
||||
import { BuiltInPluginService } from "../../pipeline/service/builtin-plugin-service.js";
|
||||
import { merge } from "lodash-es";
|
||||
@@ -17,8 +18,115 @@ import { importRuntime as importRuntimeDirect, getRuntimeDepsService, pluginRegi
|
||||
export type PluginImportReq = {
|
||||
content: string;
|
||||
override?: boolean;
|
||||
type?: "custom" | "store";
|
||||
};
|
||||
|
||||
export type OnlinePluginListReq = {
|
||||
pluginType?: string;
|
||||
group?: string;
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
export type OnlinePluginInstallReq = {
|
||||
fullName: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type OnlinePluginBean = {
|
||||
id?: number;
|
||||
appId?: number;
|
||||
author?: string;
|
||||
pluginType?: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
group?: string;
|
||||
desc?: string;
|
||||
latest?: string;
|
||||
status?: string;
|
||||
downloadCount?: number;
|
||||
installed?: boolean;
|
||||
installedVersion?: string;
|
||||
upgradeAvailable?: boolean;
|
||||
localPluginId?: number;
|
||||
localDisabled?: boolean;
|
||||
syncTime?: number;
|
||||
};
|
||||
|
||||
const ONLINE_PLUGIN_SYNC_PAGE_SIZE = 200;
|
||||
|
||||
export function parseOnlinePluginFullName(fullName: string) {
|
||||
const parts = (fullName || "")
|
||||
.trim()
|
||||
.split("/")
|
||||
.map(item => item.trim());
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1] || parts[1].includes(":") || parts[1].includes("/")) {
|
||||
throw new Error("插件标识格式错误");
|
||||
}
|
||||
return {
|
||||
author: parts[0],
|
||||
name: parts[1],
|
||||
};
|
||||
}
|
||||
|
||||
function compareOnlinePluginVersion(current: string, latest: string) {
|
||||
const normalize = (version: string) => {
|
||||
return (version || "").trim().replace(/^[vV]/, "");
|
||||
};
|
||||
const currentText = normalize(current);
|
||||
const latestText = normalize(latest);
|
||||
const currentParts = currentText.split(".");
|
||||
const latestParts = latestText.split(".");
|
||||
const maxLength = Math.max(currentParts.length, latestParts.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index++) {
|
||||
const currentPart = Number(currentParts[index] || 0);
|
||||
const latestPart = Number(latestParts[index] || 0);
|
||||
if (!Number.isInteger(currentPart) || !Number.isInteger(latestPart)) {
|
||||
return currentText.localeCompare(latestText);
|
||||
}
|
||||
if (currentPart > latestPart) {
|
||||
return 1;
|
||||
}
|
||||
if (currentPart < latestPart) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isOnlinePluginUpgradeAvailable(installedVersion?: string, latestVersion?: string) {
|
||||
const installed = (installedVersion || "").trim();
|
||||
const latest = (latestVersion || "").trim();
|
||||
if (!installed || !latest) {
|
||||
return false;
|
||||
}
|
||||
return compareOnlinePluginVersion(installed, latest) < 0;
|
||||
}
|
||||
|
||||
function fillOnlinePluginYamlVersion(content: string, version?: string) {
|
||||
const versionText = (version || "").trim();
|
||||
if (!versionText) {
|
||||
return content;
|
||||
}
|
||||
const loaded = yaml.load(content);
|
||||
if (!loaded || typeof loaded !== "object" || Array.isArray(loaded)) {
|
||||
throw new Error("插件 YAML 内容格式错误");
|
||||
}
|
||||
return yaml.dump(
|
||||
{
|
||||
...(loaded as Record<string, any>),
|
||||
version: versionText,
|
||||
},
|
||||
{
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function isBareModuleSpecifier(modulePath: string) {
|
||||
if (modulePath.startsWith(".") || modulePath.startsWith("/") || modulePath.startsWith("file:") || modulePath.startsWith("node:")) {
|
||||
return false;
|
||||
@@ -45,9 +153,15 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
@InjectEntityModel(PluginEntity)
|
||||
repository: Repository<PluginEntity>;
|
||||
|
||||
@InjectEntityModel(PluginMarketItemEntity)
|
||||
pluginMarketItemRepository: Repository<PluginMarketItemEntity>;
|
||||
|
||||
@Inject()
|
||||
builtInPluginService: BuiltInPluginService;
|
||||
|
||||
@Inject("plusService")
|
||||
plusService: PlusService;
|
||||
|
||||
//@ts-ignore
|
||||
getRepository() {
|
||||
return this.repository;
|
||||
@@ -269,6 +383,234 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
await this.refreshPluginDeps();
|
||||
}
|
||||
|
||||
private getOnlinePluginFullName(item: OnlinePluginBean) {
|
||||
if (item.fullName) {
|
||||
return item.fullName;
|
||||
}
|
||||
if (item.author && item.name) {
|
||||
return `${item.author}/${item.name}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private normalizeOnlinePluginMarketItem(item: OnlinePluginBean, syncTime: number, old?: PluginMarketItemEntity) {
|
||||
return this.pluginMarketItemRepository.create({
|
||||
id: old?.id,
|
||||
appId: item.appId,
|
||||
fullName: this.getOnlinePluginFullName(item),
|
||||
author: item.author,
|
||||
name: item.name,
|
||||
pluginType: item.pluginType,
|
||||
title: item.title,
|
||||
icon: item.icon,
|
||||
group: item.group,
|
||||
desc: item.desc,
|
||||
latest: item.latest,
|
||||
status: item.status,
|
||||
downloadCount: item.downloadCount,
|
||||
syncTime,
|
||||
updateTime: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private toOnlinePluginBean(item: PluginMarketItemEntity): OnlinePluginBean {
|
||||
return {
|
||||
id: item.id,
|
||||
appId: item.appId,
|
||||
fullName: item.fullName,
|
||||
author: item.author,
|
||||
name: item.name,
|
||||
pluginType: item.pluginType,
|
||||
title: item.title,
|
||||
icon: item.icon,
|
||||
group: item.group,
|
||||
desc: item.desc,
|
||||
latest: item.latest,
|
||||
status: item.status,
|
||||
downloadCount: item.downloadCount,
|
||||
syncTime: item.syncTime,
|
||||
};
|
||||
}
|
||||
|
||||
private async findOnlinePluginMarketItems(req: OnlinePluginListReq) {
|
||||
const query = req || {};
|
||||
const keyword = (query.keyword || "").trim().toLowerCase();
|
||||
const builder = this.pluginMarketItemRepository.createQueryBuilder("item");
|
||||
if (query.pluginType) {
|
||||
builder.andWhere("item.pluginType = :pluginType", {
|
||||
pluginType: query.pluginType,
|
||||
});
|
||||
}
|
||||
if (query.group) {
|
||||
builder.andWhere("item.group = :group", {
|
||||
group: query.group,
|
||||
});
|
||||
}
|
||||
if (keyword) {
|
||||
builder.andWhere(
|
||||
new Brackets(qb => {
|
||||
qb.where("LOWER(item.fullName) LIKE :keyword", { keyword: `%${keyword}%` })
|
||||
.orWhere("LOWER(item.author) LIKE :keyword", { keyword: `%${keyword}%` })
|
||||
.orWhere("LOWER(item.name) LIKE :keyword", { keyword: `%${keyword}%` })
|
||||
.orWhere("LOWER(item.title) LIKE :keyword", { keyword: `%${keyword}%` })
|
||||
.orWhere("LOWER(item.desc) LIKE :keyword", { keyword: `%${keyword}%` })
|
||||
.orWhere("LOWER(item.group) LIKE :keyword", { keyword: `%${keyword}%` })
|
||||
.orWhere("LOWER(item.pluginType) LIKE :keyword", { keyword: `%${keyword}%` });
|
||||
})
|
||||
);
|
||||
}
|
||||
return await builder.orderBy("item.pluginType", "ASC").addOrderBy("item.group", "ASC").addOrderBy("item.title", "ASC").addOrderBy("item.fullName", "ASC").addOrderBy("item.id", "ASC").getMany();
|
||||
}
|
||||
|
||||
private async findInstalledOnlinePlugin(parsed: { author: string; name: string }, record: OnlinePluginBean) {
|
||||
const fullName = record.fullName || `${parsed.author}/${parsed.name}`;
|
||||
const where: any[] = [
|
||||
{
|
||||
author: parsed.author,
|
||||
name: parsed.name,
|
||||
},
|
||||
{
|
||||
name: fullName,
|
||||
},
|
||||
];
|
||||
if (record.pluginType) {
|
||||
where.push({
|
||||
pluginType: record.pluginType,
|
||||
name: parsed.name,
|
||||
});
|
||||
}
|
||||
return await this.repository.findOne({
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
private async attachOnlineInstallState(list: OnlinePluginBean[]) {
|
||||
const records: OnlinePluginBean[] = [];
|
||||
for (const item of list) {
|
||||
const record: OnlinePluginBean = { ...item };
|
||||
const fullName = this.getOnlinePluginFullName(record);
|
||||
record.fullName = fullName;
|
||||
|
||||
let parsed: { author: string; name: string };
|
||||
try {
|
||||
parsed = parseOnlinePluginFullName(fullName);
|
||||
} catch (e) {
|
||||
record.installed = false;
|
||||
record.upgradeAvailable = false;
|
||||
records.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
const localPlugin = await this.findInstalledOnlinePlugin(parsed, record);
|
||||
record.installed = !!localPlugin;
|
||||
record.localPluginId = localPlugin?.id;
|
||||
record.localDisabled = localPlugin?.disabled;
|
||||
record.installedVersion = localPlugin?.version;
|
||||
record.upgradeAvailable = localPlugin ? isOnlinePluginUpgradeAvailable(localPlugin.version, record.latest) : false;
|
||||
records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async onlinePluginList(req: OnlinePluginListReq) {
|
||||
const list = await this.findOnlinePluginMarketItems(req);
|
||||
return await this.attachOnlineInstallState(list.map(item => this.toOnlinePluginBean(item)));
|
||||
}
|
||||
|
||||
async syncOnlinePluginList() {
|
||||
await this.plusService.register();
|
||||
|
||||
// 只同步市场列表元数据,插件 YAML 内容仍然在安装时按版本下载。
|
||||
const pluginMap = new Map<string, OnlinePluginBean>();
|
||||
let pageStart = 0;
|
||||
while (true) {
|
||||
const res = await this.plusService.request({
|
||||
url: "/activation/plugin/list",
|
||||
method: "post",
|
||||
data: {
|
||||
page: {
|
||||
start: pageStart,
|
||||
limit: ONLINE_PLUGIN_SYNC_PAGE_SIZE,
|
||||
orderBy: [
|
||||
{
|
||||
name: "id",
|
||||
asc: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const list = res?.list || [];
|
||||
for (const item of list) {
|
||||
const record = { ...item };
|
||||
const fullName = this.getOnlinePluginFullName(record);
|
||||
if (!fullName) {
|
||||
continue;
|
||||
}
|
||||
record.fullName = fullName;
|
||||
pluginMap.set(fullName, record);
|
||||
}
|
||||
if (list.length < ONLINE_PLUGIN_SYNC_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
pageStart += ONLINE_PLUGIN_SYNC_PAGE_SIZE;
|
||||
}
|
||||
const existingList = await this.pluginMarketItemRepository.find();
|
||||
const existingMap = new Map(existingList.map(item => [item.fullName, item]));
|
||||
const syncTime = Date.now();
|
||||
const records = Array.from(pluginMap.values()).map(item => {
|
||||
return this.normalizeOnlinePluginMarketItem(item, syncTime, existingMap.get(item.fullName));
|
||||
});
|
||||
await this.pluginMarketItemRepository.save(records);
|
||||
return await this.onlinePluginList({});
|
||||
}
|
||||
|
||||
async installOnlinePlugin(req: OnlinePluginInstallReq) {
|
||||
const fullName = (req.fullName || "").trim();
|
||||
parseOnlinePluginFullName(fullName);
|
||||
|
||||
await this.plusService.register();
|
||||
const res = await this.plusService.request({
|
||||
url: "/activation/plugin/download",
|
||||
method: "post",
|
||||
data: {
|
||||
fullName,
|
||||
version: req.version || "",
|
||||
},
|
||||
});
|
||||
if (!res?.content) {
|
||||
throw new Error("插件内容为空");
|
||||
}
|
||||
|
||||
const content = fillOnlinePluginYamlVersion(res.content, res.version?.version || req.version);
|
||||
const importRes = await this.importPlugin({
|
||||
content,
|
||||
override: true,
|
||||
type: "store",
|
||||
});
|
||||
await this.refreshOnlinePluginDownloadCount(res.fullName || fullName, res.plugin?.downloadCount);
|
||||
return {
|
||||
...importRes,
|
||||
fullName: res.fullName || fullName,
|
||||
plugin: res.plugin,
|
||||
version: res.version,
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshOnlinePluginDownloadCount(fullName: string, downloadCount?: number) {
|
||||
if (!this.pluginMarketItemRepository || !fullName || downloadCount == null) {
|
||||
return;
|
||||
}
|
||||
await this.pluginMarketItemRepository.update(
|
||||
{
|
||||
fullName,
|
||||
},
|
||||
{
|
||||
downloadCount,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async unRegisterById(id: any) {
|
||||
const item = await this.info(id);
|
||||
if (!item) {
|
||||
@@ -533,6 +875,7 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
|
||||
const pluginEntity = {
|
||||
...loaded,
|
||||
type: req.type || loaded.type || "custom",
|
||||
metadata: yaml.dump(metadata),
|
||||
extra: yaml.dump(extra),
|
||||
content: loaded.content,
|
||||
|
||||
Reference in New Issue
Block a user