chore: plugin 有store page版本

This commit is contained in:
xiaojunnuo
2026-07-25 13:46:11 +08:00
parent ce4839bd80
commit 0bf9a2d3da
23 changed files with 2830 additions and 158 deletions
+6 -1
View File
@@ -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 容器,否则已缓存模块不会重新读取",
@@ -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;
}
}
@@ -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>
@@ -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,