feat: 支持在线插件下载安装审核等

This commit is contained in:
xiaojunnuo
2026-08-02 00:46:29 +08:00
parent a237179a72
commit 255960d434
47 changed files with 3549 additions and 1331 deletions
@@ -40,7 +40,6 @@ export default {
headerMenus: "Top Menu Settings",
sysAccess: "System-level Authorization",
sysPlugin: "Plugin Management",
sysPluginEdit: "Edit Plugin",
sysPluginConfig: "Certificate Plugin Configuration",
accountBind: "Account Binding",
permissionManager: "Permission Management",
@@ -59,6 +59,7 @@ export default {
onlinePluginUninstall: "Uninstall",
onlinePluginStatus: "Status",
onlinePluginInstalled: "Installed",
onlinePluginAiReviewPassed: "AI reviewed",
onlinePluginEnabled: "Enabled",
onlinePluginDisabled: "Disabled",
onlinePluginClickToEnable: "Click to enable",
@@ -69,9 +70,44 @@ export default {
onlinePluginCurrentVersion: "Current {version}",
onlinePluginAlreadyLatest: "Already up to date",
onlinePluginClickToUpdate: "Click to update",
onlinePluginSelfAuthored: "Mine",
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.',
onlinePluginPublish: "Publish to Plugin Market",
onlinePluginPublishManage: "Plugin Publish Management",
onlinePluginPublishConfirm: 'Publish "{name}" to the plugin market? It will be checked by AI and then wait for administrator review.',
onlinePluginPublishConfirmTip: "Please confirm the plugin information. It will be checked by AI and then wait for administrator review.",
onlinePluginPublishManageTip: "Confirm the current plugin information. If it has been submitted before, versions, publish status, and rejection reasons are shown here.",
onlinePluginPublishSubmit: "Submit Publish",
onlinePluginPublishSuccess: "Submitted, waiting for AI security review",
onlinePluginPublishVersionRequired: "Please set the plugin version before publishing",
onlinePluginPublishStatus: "Publish Status",
onlinePluginNotSubmitted: "Not Submitted",
onlinePluginVersionManagement: "Versions",
onlinePluginLatestVersion: "Latest Version",
onlinePluginCurrentRelease: "This Release",
onlinePluginPublishPrompt: "Release Notes",
onlinePluginVersionRejectedReason: "Rejection Reason",
onlinePluginNoSubmittedVersion: "No release has been submitted yet",
onlinePluginVersionFormatError: "Use a dotted numeric version, for example 1.0.0",
onlinePluginVersionBaselineError: "Enter a version greater than v{version}",
onlinePluginStatusDraft: "Draft",
onlinePluginStatusReviewing: "Reviewing",
onlinePluginStatusPublished: "Published",
onlinePluginStatusRejected: "Rejected",
onlinePluginStatusOffline: "Offline",
onlinePluginReviewAiPending: "Pending AI Review",
onlinePluginReviewPending: "Pending Manual Review",
onlinePluginReviewPassed: "Passed",
onlinePluginReviewRejected: "Rejected",
onlinePluginAuthorRegister: "Register Plugin Author",
onlinePluginAuthorName: "Author Name",
onlinePluginAuthorDisplayName: "Display Name",
onlinePluginAuthorNotRegistered: "Not Registered",
onlinePluginAuthorNameRequired: "Please enter an author name",
onlinePluginAuthorNameHelper: "Used as the plugin market author prefix. Each bound user can register only one author.",
onlinePluginAuthorNameRuleMsg: "Must start with a letter and contain only letters, digits, hyphens, or underscores",
pleaseSelectRecord: "Please select records first",
clearRuntimeDeps: "Clear Runtime Deps Cache",
clearRuntimeDepsTooltip: "Restart the certd container after clearing, otherwise cached modules will not be reloaded",
@@ -41,7 +41,6 @@ export default {
headerMenus: "顶部菜单设置",
sysAccess: "系统级授权",
sysPlugin: "插件管理",
sysPluginEdit: "编辑插件",
sysPluginConfig: "证书插件配置",
accountBind: "账号绑定",
permissionManager: "权限管理",
@@ -43,7 +43,7 @@ export default {
pluginBetaWarning: "自定义插件处于BETA测试版,后续可能会有破坏性变更",
localPlugin: "本地插件",
pluginMarket: "插件市场",
installedStorePlugin: "已安装",
installedStorePlugin: "插件市场",
onlinePluginManagement: "在线插件",
onlinePluginSearch: "搜索在线插件",
onlinePluginSync: "同步插件市场",
@@ -58,6 +58,7 @@ export default {
onlinePluginUninstall: "卸载",
onlinePluginStatus: "状态",
onlinePluginInstalled: "已安装",
onlinePluginAiReviewPassed: "AI审核通过",
onlinePluginEnabled: "已启用",
onlinePluginDisabled: "已禁用",
onlinePluginClickToEnable: "点击启用",
@@ -68,9 +69,44 @@ export default {
onlinePluginCurrentVersion: "当前 {version}",
onlinePluginAlreadyLatest: "当前已是最新版本",
onlinePluginClickToUpdate: "点击更新",
onlinePluginSelfAuthored: "我的",
onlinePluginInstallSuccess: "在线插件安装成功",
onlinePluginUninstallSuccess: "在线插件卸载成功",
onlinePluginDeleteConfirm: "确定要卸载在线插件「{name}」吗?如果该插件已被流水线使用,删除可能会导致执行失败。",
onlinePluginPublish: "发布到插件市场",
onlinePluginPublishManage: "插件发布管理",
onlinePluginPublishConfirm: "确定要将「{name}」发布到插件市场吗?提交后会进行 AI 安全审查,通过后等待管理员审核。",
onlinePluginPublishConfirmTip: "请确认以下插件信息,提交后会进行 AI 安全审查,通过后等待管理员审核。",
onlinePluginPublishManageTip: "请确认当前插件信息;如已提交过发布,可以在这里查看版本、发布状态和拒绝原因。",
onlinePluginPublishSubmit: "提交发布",
onlinePluginPublishSuccess: "已提交,等待 AI 安全审查",
onlinePluginPublishVersionRequired: "请先设置插件版本号后再发布",
onlinePluginPublishStatus: "发布状态",
onlinePluginNotSubmitted: "未提交",
onlinePluginVersionManagement: "版本管理",
onlinePluginLatestVersion: "最新版本",
onlinePluginCurrentRelease: "本次发布",
onlinePluginPublishPrompt: "发布提示",
onlinePluginVersionRejectedReason: "拒绝原因",
onlinePluginNoSubmittedVersion: "尚未提交过发布版本",
onlinePluginVersionFormatError: "版本号只能使用数字点分格式,例如 1.0.0",
onlinePluginVersionBaselineError: "请输入大于 v{version} 的版本号",
onlinePluginStatusDraft: "草稿",
onlinePluginStatusReviewing: "审核中",
onlinePluginStatusPublished: "已发布",
onlinePluginStatusRejected: "已拒绝",
onlinePluginStatusOffline: "已下架",
onlinePluginReviewAiPending: "待 AI 审查",
onlinePluginReviewPending: "待人工审核",
onlinePluginReviewPassed: "已通过",
onlinePluginReviewRejected: "已拒绝",
onlinePluginAuthorRegister: "注册插件作者",
onlinePluginAuthorName: "作者名称",
onlinePluginAuthorDisplayName: "显示名称",
onlinePluginAuthorNotRegistered: "未注册",
onlinePluginAuthorNameRequired: "请输入作者名称",
onlinePluginAuthorNameHelper: "作为插件市场作者前缀,同一个绑定用户只能注册一个作者",
onlinePluginAuthorNameRuleMsg: "必须以英文字母开头,只能包含英文字母、数字、中划线或下划线",
pleaseSelectRecord: "请先勾选记录",
clearRuntimeDeps: "清理第三方依赖缓存",
clearRuntimeDepsTooltip: "清除后需重启 certd 容器,否则已缓存模块不会重新读取",
@@ -154,19 +154,6 @@ export const sysResources = [
auth: true,
},
},
{
title: "certd.sysResources.sysPluginEdit",
name: "SysPluginEdit",
path: "/sys/plugin/edit",
component: "/sys/plugin/edit.vue",
meta: {
isMenu: false,
icon: "ion:extension-puzzle",
permission: "sys:settings:view",
keepAlive: true,
auth: true,
},
},
{
title: "certd.sysResources.sysPluginConfig",
name: "SysPluginConfig",
@@ -153,6 +153,22 @@ export class PluginGroups {
}
}
function filterInstalledPluginGroups(groups: { [key: string]: PluginGroup }) {
const filteredGroups: { [key: string]: PluginGroup } = {};
for (const [key, group] of Object.entries(groups)) {
filteredGroups[key] = {
...group,
plugins: group.plugins.filter(plugin => {
if (plugin.type !== "store") {
return true;
}
return plugin.installed === true;
}),
};
}
return filteredGroups;
}
export const usePluginStore = defineStore({
id: "app.plugin",
state: (): PluginState => ({
@@ -162,8 +178,9 @@ export const usePluginStore = defineStore({
actions: {
async reload() {
const groups = await api.GetGroups({});
this.group = new PluginGroups(groups, { mergeSetting: true });
this.originGroup = new PluginGroups(cloneDeep(groups));
const installedGroups = filterInstalledPluginGroups(groups);
this.group = new PluginGroups(installedGroups, { mergeSetting: true });
this.originGroup = new PluginGroups(cloneDeep(installedGroups));
console.log("group", this.group);
console.log("originGroup", this.originGroup);
},
@@ -75,3 +75,9 @@ footer {
.ant-input-number {
min-width: 150px;
}
.ant-dropdown-menu-title-content{
display: flex;
align-items: center;
gap: 6px;
}
@@ -36,6 +36,116 @@
align-items: center;
}
.cd-card-section {
padding: 14px;
border: 1px solid #e8edf3;
border-radius: 10px;
background: #ffffff;
}
.cd-card-section-title {
margin-bottom: 12px;
color: #1f2937;
font-weight: 700;
&.cd-card-section-title--compact {
margin-bottom: 10px;
}
}
.cd-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
}
.cd-meta-item {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
align-items: start;
gap: 10px;
}
.cd-meta-label {
color: #7b8794;
}
.cd-meta-value {
min-width: 0;
color: #1f2937;
font-weight: 500;
word-break: break-all;
}
.cd-tip-box {
padding: 10px 12px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #f6f8fb;
color: #526172;
line-height: 22px;
&.cd-tip-box-warning {
border-color: #ffd591;
background: #fff7e6;
color: #ad6800;
}
&.cd-tip-box-danger {
border-color: #ffccc7;
background: #fff2f0;
color: #cf1322;
line-height: 21px;
}
}
.cd-tip-title {
margin-bottom: 3px;
font-weight: 600;
}
.cd-form-row {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: start;
gap: 10px;
}
.cd-form-label {
padding-top: 5px;
color: #5f6b7a;
}
.cd-text-input {
box-sizing: border-box;
width: 100%;
height: 32px;
padding: 4px 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
background: #ffffff;
color: #1f2937;
&.cd-text-input-error {
border-color: #ff4d4f;
}
}
.cd-field-error {
margin-top: 6px;
color: #cf1322;
font-size: 12px;
}
.cd-text-danger {
color: #cf1322;
}
.cd-text-muted {
color: #8c8c8c;
}
.ant-drawer-content {
&.fullscreen {
position: fixed;
@@ -140,3 +250,9 @@ button.ant-btn.ant-btn-default.isPlus {
}
}
}
.fs-form-none-content {
.fs-form-wrapper{
.fs-form-content{display: none;}
}
}
@@ -9,12 +9,14 @@ export type FormOptionReq = {
initialForm?: any;
zIndex?: number;
wrapper?: any;
noneForm?: boolean; //是否隐藏表单,只显示注入的body
};
export function useFormDialog() {
const { openCrudFormDialog } = useFormWrapper();
async function openFormDialog(req: FormOptionReq) {
const noneForm = req.noneForm ?? Object.keys(req.columns).length === 0;
function createCrudOptions() {
const warpper = merge(
{
@@ -24,6 +26,7 @@ export function useFormDialog() {
slots: {
"form-body-top": req.body,
},
wrapClassName: noneForm ? "fs-form-none-content" : "",
},
req.wrapper
);
@@ -1,5 +1,5 @@
<template>
<a-drawer v-model:open="stepDrawerVisible" :wrap-style="{ maxWidth: '100vw' }" placement="right" :closable="true" width="760px" class="step-form-drawer" :class="{ fullscreen }">
<a-drawer v-model:open="stepDrawerVisible" placement="right" :closable="true" width="760px" class="step-form-drawer" :class="{ fullscreen }">
<template #title>
<div>
编辑步骤
@@ -30,7 +30,7 @@
</a-tabs>
</div>
<template #footer>
<div style="padding: 20px; margin-left: 100px">
<div class="bottom-button">
<a-button v-if="editMode" type="primary" @click="stepTypeSave"> 确定</a-button>
</div>
</template>
@@ -396,6 +396,10 @@ defineExpose({
.step-form-drawer {
max-width: 100%;
.ant-drawer-content-wrapper {
max-width: 100vw;
}
.ant-tabs-nav .ant-tabs-tab {
margin-top: 10px !important;
padding: 8px 14px !important;
@@ -434,6 +438,83 @@ defineExpose({
}
.pi-step-form {
.step-plugin-source-pane-local,
.step-plugin-source-pane-online {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.step-plugin-search {
flex: none;
padding: 0 0 12px;
}
.step-plugin-selector-tabs {
min-height: 0;
> .ant-tabs-nav {
width: 136px;
flex: 0 0 136px;
overflow: hidden;
}
> .ant-tabs-nav .ant-tabs-nav-wrap,
> .ant-tabs-nav .ant-tabs-nav-list {
width: 100%;
}
> .ant-tabs-nav .ant-tabs-tab {
width: 100%;
box-sizing: border-box;
}
.cd-step-form-tab-label {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
.fs-icon {
display: flex;
align-items: center;
color: #00b7ff;
svg {
vertical-align: middle !important;
display: flex;
align-items: center;
}
}
> div:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
> .ant-tabs-content-holder {
min-width: 0;
min-height: 0;
flex: 1;
overflow-x: hidden;
overflow-y: auto;
padding-right: 10px;
}
> .ant-tabs-content-holder > .ant-tabs-content {
height: auto;
min-height: 100%;
}
> .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane {
padding-right: 0 !important;
overflow: visible !important;
}
}
.bottom-button {
padding: 20px;
padding-bottom: 5px;
@@ -3,8 +3,8 @@
<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">
<a-tabs v-model:active-key="pluginGroupActive" tab-position="left" class="step-plugin-selector-tabs flex-1 overflow-hidden h-full">
<a-tab-pane v-for="group of computedLocalPluginGroups" :key="group.key" class="step-plugin-list-pane">
<template #tab>
<div class="cd-step-form-tab-label" @click="handleLocalPluginGroupChange(group.key)">
<fs-icon :icon="group.icon" class="mr-2" />
@@ -3,8 +3,8 @@
<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">
<a-tabs v-model:active-key="onlinePluginGroupActive" tab-position="left" class="step-plugin-selector-tabs step-market-tabs flex-1 overflow-hidden h-full">
<a-tab-pane v-for="group of computedOnlinePluginGroups" :key="group.key" class="step-plugin-list-pane">
<template #tab>
<div class="cd-step-form-tab-label" @click="handleOnlinePluginGroupChange(group.key)">
<fs-icon :icon="group.icon" class="mr-2" />
@@ -17,19 +17,7 @@
<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"
/>
<PluginItemCard :plugin="item" simple :current="isOnlinePluginCurrent(item)" @click="onlinePluginSelected(item)" @dblclick="handleOnlinePluginCardDblclick(item)" @changed="handleOnlinePluginChanged" />
</a-col>
</a-row>
<div class="online-plugin-pagination">
@@ -43,7 +31,6 @@
</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";
@@ -71,7 +58,6 @@ 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: "",
@@ -215,86 +201,15 @@ function getPagedOnlinePlugins(groupKey: string) {
return list.slice(start, start + onlinePageSize);
}
function getOnlinePluginActionKey(plugin: pluginApi.OnlinePluginBean, action: "install" | "uninstall" | "toggle") {
if (!plugin.fullName) {
return "";
async function handleOnlinePluginChanged(payload: { plugin: any; action: string }) {
if (payload.action === "uninstall" && props.selectedType === payload.plugin.fullName) {
emit("uninstalled", payload.plugin);
}
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"));
if (payload.action === "install" || payload.action === "uninstall") {
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 = "";
}
},
});
await loadPluginGroups();
await loadOnlinePlugins(true, { silent: true });
}
function getInstalledPlugin(plugin: any) {
@@ -13,7 +13,7 @@
<script setup lang="tsx">
import { IframeClient } from "@certd/lib-iframe";
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useUserStore } from "/@/store/user";
import { useSettingStore } from "/@/store/settings";
import * as api from "./api";
@@ -23,6 +23,7 @@ defineOptions({
name: "AccountBind",
});
const iframeRef = ref();
let iframeClient: IframeClient | undefined;
const userStore = useUserStore();
const settingStore = useSettingStore();
@@ -40,9 +41,10 @@ type SubjectInfo = {
installAt?: number;
vipType?: string;
expiresAt?: number;
userId?: number;
};
onMounted(() => {
const iframeClient = new IframeClient(iframeRef.value, (e: any) => {
iframeClient = new IframeClient(iframeRef.value, (e: any) => {
notification.error({
message: " error",
description: e.message,
@@ -54,6 +56,7 @@ onMounted(() => {
installAt: settingStore.installInfo.installTime,
vipType: settingStore.plusInfo.vipType || "free",
expiresAt: settingStore.plusInfo.expireTime,
userId: settingStore.installInfo.bindUserId || undefined,
};
return subjectInfo;
});
@@ -84,6 +87,11 @@ onMounted(() => {
});
});
});
onBeforeUnmount(() => {
iframeClient?.destroy();
iframeClient = undefined;
});
</script>
<style lang="less">
@@ -85,7 +85,9 @@ export async function ImportPlugin(body: any) {
export type OnlinePluginBean = {
id?: number;
appId?: number;
developerId?: number;
author?: string;
type?: string;
pluginType?: string;
name?: string;
fullName?: string;
@@ -96,14 +98,42 @@ export type OnlinePluginBean = {
latest?: string;
status?: string;
downloadCount?: number;
score?: number;
aiCheckStatus?: string;
selfAuthored?: boolean;
installed?: boolean;
installedVersion?: string;
upgradeAvailable?: boolean;
localPluginId?: number;
localDisabled?: boolean;
localEditable?: boolean;
syncTime?: number;
};
export type OnlinePluginVersionBean = {
id?: number;
pluginId?: number;
version?: string;
minAppVersion?: string;
maxAppVersion?: string;
status?: string;
publishedAt?: number;
reviewStatus?: string;
reviewReason?: string;
reviewedAt?: number;
aiCheckStatus?: string;
aiCheckResult?: string;
};
export type OnlinePluginCommentBean = {
id?: number;
createdAt?: number;
updatedAt?: number;
userId?: number;
score?: number;
comment?: string;
};
export async function OnlinePluginList(body: { pluginType?: string; group?: string; keyword?: string }): Promise<OnlinePluginBean[]> {
return await request({
url: apiPrefix + "/online/list",
@@ -119,6 +149,13 @@ export async function OnlinePluginSync(): Promise<OnlinePluginBean[]> {
});
}
export async function OnlinePluginSetting(): Promise<{ lastSyncTime?: number }> {
return await request({
url: apiPrefix + "/online/setting",
method: "post",
});
}
export async function OnlinePluginInstall(body: { fullName: string; version?: string }) {
return await request({
url: apiPrefix + "/online/install",
@@ -127,6 +164,94 @@ export async function OnlinePluginInstall(body: { fullName: string; version?: st
});
}
export async function OnlinePluginDetail(body: { pluginId?: number; fullName?: string; commentPageNo?: number; commentPageSize?: number }): Promise<{
plugin: OnlinePluginBean;
versions: OnlinePluginVersionBean[];
myScore?: number;
myComment?: string;
comments?: OnlinePluginCommentBean[];
commentsTotal?: number;
}> {
return await request({
url: apiPrefix + "/online/detail",
method: "post",
data: body,
});
}
export async function OnlinePluginSource(body: { pluginId?: number; fullName?: string; version?: string }): Promise<{ plugin: OnlinePluginBean; version: OnlinePluginVersionBean; content: string }> {
return await request({
url: apiPrefix + "/online/source",
method: "post",
data: body,
});
}
export async function OnlinePluginRate(body: { pluginId?: number; fullName?: string; score: number; comment: string }): Promise<{ plugin: OnlinePluginBean; myScore?: number; myComment?: string }> {
return await request({
url: apiPrefix + "/online/rate",
method: "post",
data: body,
});
}
export async function OnlinePluginSubmitVersion(body: { fullName: string; version: string; content: string; minAppVersion?: string; maxAppVersion?: string }) {
return await request({
url: apiPrefix + "/online/version/submit",
method: "post",
data: body,
});
}
export async function OnlinePluginPublish(body: { id: number; version?: string; minAppVersion?: string; maxAppVersion?: string }) {
return await request({
url: apiPrefix + "/online/publish",
method: "post",
data: body,
});
}
export async function OnlinePluginPublishInfo(body: { id: number }): Promise<{
localPlugin: OnlinePluginBean;
authorRegistered?: boolean;
author?: OnlinePluginAuthorBean;
marketPlugin?: OnlinePluginBean;
versions: OnlinePluginVersionBean[];
}> {
return await request({
url: apiPrefix + "/online/publish/info",
method: "post",
data: body,
});
}
export type OnlinePluginAuthorBean = {
id?: number;
appId?: number;
appOwnerId?: number;
developerId?: number;
name?: string;
displayName?: string;
avatar?: string;
desc?: string;
status?: string;
};
export async function OnlinePluginAuthorGet(): Promise<{ registered?: boolean; author?: OnlinePluginAuthorBean }> {
return await request({
url: apiPrefix + "/online/author/get",
method: "post",
});
}
export async function OnlinePluginAuthorAdd(body: { name: string; displayName?: string; avatar?: string; desc?: string }): Promise<OnlinePluginAuthorBean> {
return await request({
url: apiPrefix + "/online/author/add",
method: "post",
data: body,
});
}
export type PluginConfigBean = {
name: string;
disabled: boolean;
@@ -139,6 +264,9 @@ export type CertApplyPluginSysInput = {
googleCommonEabAccessId?: number;
zerosslCommonEabAccessId?: number;
litesslCommonEabAccessId?: number;
googleCommonAcmeAccountAccessId?: number;
zerosslCommonAcmeAccountAccessId?: number;
litesslCommonAcmeAccountAccessId?: number;
};
export type PluginSysSetting<T> = {
sysSetting: {
@@ -0,0 +1,136 @@
<template>
<a-modal v-model:open="visible" :width="'min(1540px, calc(100vw - 48px))'" :footer="null" destroy-on-close class="online-plugin-detail-modal">
<a-spin :spinning="loading">
<iframe v-if="iframeSrc" ref="iframeRef" class="online-plugin-detail-modal__iframe" :src="iframeSrc" @load="handleIframeLoad" />
<a-empty v-else description="插件详情地址未配置" />
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { message } from "ant-design-vue";
import { IframeClient } from "@certd/lib-iframe";
import * as api from "../api";
import { usePluginStore } from "/@/store/plugin";
import { useSettingStore } from "/src/store/settings";
const props = defineProps<{
open: boolean;
plugin?: any;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "installed", value: { plugin: any; version?: string }): void;
}>();
const pluginStore = usePluginStore();
const settingStore = useSettingStore();
const iframeRef = ref<HTMLIFrameElement>();
const loading = ref(false);
const visible = computed({
get: () => props.open,
set: value => emit("update:open", value),
});
const iframeSrc = computed(() => {
const plugin = props.plugin;
if (!plugin) {
return "";
}
const baseUrl = String(settingStore.installInfo?.accountServerBaseUrl || "").replace(/\/$/, "");
if (!baseUrl) {
return "";
}
const params = new URLSearchParams({
embedded: "true",
fullName: plugin.fullName || "",
installedVersion: plugin.installedVersion || "",
t: `${Date.now()}`,
});
return `${baseUrl}/#/app/certd/plugin/${plugin.id || 0}?${params.toString()}`;
});
let iframeClient: IframeClient;
watch(
() => props.open,
async open => {
if (!open || !props.plugin) {
loading.value = false;
return;
}
loading.value = true;
await nextTick();
setupIframeClient();
if (!iframeSrc.value) {
loading.value = false;
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
//@ts-ignore
iframeClient?.destroy();
});
function setupIframeClient() {
//@ts-ignore
iframeClient?.destroy();
if (!iframeRef.value) {
return;
}
iframeClient = new IframeClient(iframeRef.value, (error: any) => {
message.error(error?.message || "插件操作失败");
});
iframeClient.register("installPlugin", async req => {
return await installPluginVersion(req.data);
});
}
function handleIframeLoad() {
loading.value = false;
}
async function installPluginVersion(data: { fullName?: string; version?: string }) {
const fullName = data?.fullName || props.plugin?.fullName;
if (!fullName || !data?.version) {
throw new Error("插件版本信息不完整");
}
await api.OnlinePluginInstall({
fullName,
version: data.version,
});
await pluginStore.reload();
emit("installed", {
plugin: props.plugin,
version: data.version,
});
return {
version: data.version,
};
}
</script>
<style lang="less">
.online-plugin-detail-modal {
.ant-modal-body {
height: 80vh;
padding: 0;
overflow: hidden;
}
.ant-spin-nested-loading,
.ant-spin-container {
height: 100%;
}
&__iframe {
display: block;
width: 100%;
height: 80vh;
border: 0;
}
}
</style>
@@ -0,0 +1,59 @@
<template>
<div class="plugin-author-field">
<span v-if="authorName" class="plugin-author-field__name">{{ authorName }}</span>
<a-button v-else type="link" size="small" :loading="loading" @click="registerAuthor">注册作者</a-button>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from "vue";
import * as api from "../api";
import { usePluginPublish } from "../use-publish";
defineOptions({ name: "PluginAuthorField" });
const props = defineProps<{ modelValue?: string }>();
const emit = defineEmits<{ (event: "update:modelValue", value: string): void }>();
const loading = ref(false);
const registeredAuthor = ref<api.OnlinePluginAuthorBean>();
const { registerPluginAuthor } = usePluginPublish();
const authorName = computed(() => props.modelValue || registeredAuthor.value?.name || "");
async function loadAuthor() {
loading.value = true;
try {
const result = await api.OnlinePluginAuthorGet();
registeredAuthor.value = result.author;
if (!props.modelValue && result.author?.name) {
emit("update:modelValue", result.author.name);
}
} catch {
// 作者为非必填项,未绑定账号或尚未注册作者时保持为空。
} finally {
loading.value = false;
}
}
async function registerAuthor() {
const author = await registerPluginAuthor();
if (!author?.name) {
return;
}
registeredAuthor.value = author;
emit("update:modelValue", author.name);
}
onMounted(loadAuthor);
</script>
<style lang="less">
.plugin-author-field {
display: flex;
min-height: 32px;
align-items: center;
&__name {
color: #1f2937;
}
}
</style>
@@ -0,0 +1,232 @@
<template>
<div class="plugin-edit-dialog-body">
<div class="plugin-edit-dialog-body__header">
<span>插件名称</span>
<fs-copyable :model-value="pluginName" />
<a-button v-if="canEditPlugin" class="plugin-edit-dialog-body__publish" :loading="isPublishingPlugin(plugin)" @click="doPublish">发布到插件市场</a-button>
</div>
<div class="plugin-edit-dialog-body__content">
<section class="plugin-edit-dialog-body__base">
<a-tabs type="card">
<a-tab-pane key="base" tab="插件信息" />
</a-tabs>
<div class="plugin-edit-dialog-body__base-content">
<fs-form ref="baseFormRef" v-bind="formOptionsRef" />
</div>
</section>
<section class="plugin-edit-dialog-body__editor plugin-edit-dialog-body__metadata">
<a-tabs type="card">
<a-tab-pane key="metadata" tab="元数据" />
</a-tabs>
<code-editor :id="`plugin-metadata-${pluginId}`" v-model:model-value="plugin.metadata" language="yaml" @save="doSave" />
</section>
<section class="plugin-edit-dialog-body__editor plugin-edit-dialog-body__script">
<a-tabs type="card">
<a-tab-pane key="script" tab="脚本" />
</a-tabs>
<code-editor :id="`plugin-content-${pluginId}`" v-model:model-value="plugin.content" language="javascript" @save="doSave" />
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, provide, ref, type Ref } from "vue";
import { notification } from "ant-design-vue";
import { useColumns } from "@fast-crud/fast-crud";
// @ts-ignore js-yaml 没有在当前前端包中提供类型声明。
import yaml from "js-yaml";
import * as api from "../api";
import createCrudOptions from "../crud";
import { usePluginPublish } from "../use-publish";
import { usePluginStore } from "/@/store/plugin";
import { useSettingStore } from "/@/store/settings";
defineOptions({
name: "PluginEditDialogBody",
});
const props = defineProps<{
pluginId: number | string;
}>();
const emit = defineEmits<{
(event: "saved"): void;
}>();
const pluginStore = usePluginStore();
const settingStore = useSettingStore();
const plugin = ref<any>({});
const formOptionsRef: Ref = ref();
const baseFormRef: Ref = ref({});
const saveLoading = ref(false);
const { isPublishingPlugin, publishLocalPlugin } = usePluginPublish();
function initFormOptions() {
const formCrudOptions = createCrudOptions({
// 编辑弹框只复用插件表单字段,不需要 CRUD 实例。
crudExpose: {},
context: {},
});
const { buildFormOptions } = useColumns();
const formOptions = buildFormOptions(formCrudOptions.crudOptions, {});
formOptions.mode = "edit";
formOptions.col = { span: 24 };
formOptions.labelCol = { style: { width: "100px" } };
formOptionsRef.value = formOptions;
}
initFormOptions();
const pluginName = computed(() => {
if (plugin.value.author) {
return `${plugin.value.author}/${plugin.value.name}`;
}
return plugin.value.name || "";
});
const canEditPlugin = computed(() => {
const current = plugin.value || {};
if (current.type === "custom") {
return true;
}
if (current.type !== "store") {
return false;
}
const bindUserId = Number(settingStore.installInfo?.bindUserId || 0);
return !current.developerId || (!!bindUserId && Number(current.developerId) === bindUserId);
});
provide("get:plugin", () => plugin);
async function loadPlugin() {
const pluginObj = await api.GetObj(props.pluginId);
plugin.value = pluginObj;
const baseForm = { ...pluginObj };
if (baseForm.extra) {
baseForm.extra = yaml.load(baseForm.extra);
}
delete baseForm.metadata;
delete baseForm.content;
baseFormRef.value.setFormData(baseForm);
}
function validate() {
try {
yaml.load(plugin.value.metadata);
} catch (error: any) {
const message = `元数据校验失败:${error.message}`;
notification.error({ message });
throw new Error(message);
}
}
function buildSubmitForm() {
const baseForm = baseFormRef.value.getFormData();
const form = { ...plugin.value, ...baseForm };
if (form.extra) {
form.extra = yaml.dump(form.extra);
}
return form;
}
async function doSave() {
if (!canEditPlugin.value) {
notification.warning({ message: "当前绑定账号无权编辑该插件" });
return;
}
validate();
saveLoading.value = true;
try {
const form = buildSubmitForm();
await api.UpdateObj(form);
plugin.value = form;
pluginStore.clear();
notification.success({ message: "保存成功" });
emit("saved");
return form;
} finally {
saveLoading.value = false;
}
}
async function doPublish() {
const baseForm = baseFormRef.value.getFormData();
const currentPlugin = { ...plugin.value, ...baseForm };
await publishLocalPlugin(currentPlugin, {
beforePublish: doSave,
async afterPublish() {
await loadPlugin();
},
});
}
onMounted(loadPlugin);
defineExpose({
save: doSave,
});
</script>
<style lang="less">
.plugin-edit-dialog-body {
display: flex;
min-height: 680px;
flex-direction: column;
&__header {
display: flex;
min-height: 32px;
align-items: center;
color: #5f6b7a;
font-size: 13px;
}
&__publish {
margin-left: auto;
}
&__content {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: minmax(260px, 0.72fr) minmax(330px, 1fr) minmax(430px, 1.35fr);
gap: 16px;
margin-top: 12px;
}
&__base,
&__editor {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
&__base {
padding-right: 16px;
border-right: 1px solid #f0f0f0;
}
&__base-content {
min-height: 0;
flex: 1;
overflow: auto;
}
.fs-editor-code {
min-height: 0;
flex: 1;
}
.ant-tabs {
flex: none;
}
}
.dark .plugin-edit-dialog-body {
&__base {
border-right-color: #303030;
}
}
</style>
@@ -41,8 +41,6 @@
<script lang="ts" setup>
import { ref, Ref, inject, toRef } from "vue";
import { useFormWrapper } from "@fast-crud/fast-crud";
import yaml from "js-yaml";
const activeKey = ref([]);
const getPlugin: any = inject("get:plugin");
@@ -9,69 +9,80 @@
'is-installed': isInstalled,
'is-disabled': isDisabled,
}"
@click="emit('click', plugin)"
@dblclick="emit('dblclick', plugin)"
@click="handleCardClick"
@dblclick="handleCardDoubleClick"
>
<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>
<a v-if="showEditButton" class="plugin-card__title" :title="plugin.title || plugin.name" href="#" @click.stop.prevent="editPlugin">
{{ plugin.title || plugin.name }}
</router-link>
</a>
<span v-else class="plugin-card__title" :title="plugin.title || plugin.name">{{ plugin.title || plugin.name }}</span>
<a-tooltip v-if="plugin.aiCheckStatus === 'passed'" :title="t('certd.onlinePluginAiReviewPassed')">
<fs-icon class="plugin-card__ai-check-icon" icon="ion:shield-checkmark-outline" />
</a-tooltip>
</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") }}
<div v-if="showEditButton || showConfigButton || showToolMenu" class="plugin-card__tools">
<a-tooltip v-if="showEditButton" title="编辑">
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="editPlugin">
<template #icon>
<fs-icon icon="ion:create-outline" />
</template>
</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-tooltip>
<a-tooltip v-if="copyHandler && canCopyPlugin" title="复制">
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="copyPlugin">
<template #icon>
<fs-icon icon="ion:copy-outline" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="isOwnImportedPlugin" :title="t('certd.export')">
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="exportPlugin">
<template #icon>
<fs-icon icon="ion:download-outline" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canPublishPlugin" :title="t('certd.onlinePluginPublish')">
<a-button class="plugin-card__tool" type="text" size="small" :loading="isPublishingPlugin(plugin)" @click.stop="publishPlugin">
<template #icon>
<fs-icon icon="ion:cloud-upload-outline" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="showConfigButton" title="配置">
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="openConfig">
<template #icon>
<fs-icon icon="ion:settings-outline" />
</template>
</a-button>
</a-tooltip>
</div>
<div v-if="source === 'local' && canRemoveLocal" class="plugin-card__action-zone plugin-card__local-action-zone">
<a-tooltip title="删除">
<a-button class="plugin-card__action-button" type="text" size="small" danger :loading="isActionLoading('remove')" @click.stop="removePlugin">
<template #icon>
<fs-icon icon="ion:trash-outline" />
</template>
</a-button>
</a-tooltip>
</div>
<template v-else-if="plugin.installed && source !== 'local'">
<div v-if="plugin.localPluginId" class="plugin-card__action-zone" :class="{ 'is-loading': isActionLoading('uninstall') }">
<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)">
<a-button class="plugin-card__action-button" size="small" danger ghost :loading="isActionLoading('uninstall')" @click.stop="uninstallPlugin">
{{ 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)">
<div v-else-if="source !== 'local'" class="plugin-card__action-zone plugin-card__install-zone" :class="{ 'is-loading': isActionLoading('install') }">
<a-button class="plugin-card__action-button" size="small" type="primary" :loading="isActionLoading('install')" @click.stop="installPlugin">
{{ t("certd.onlinePluginInstall") }}
</a-button>
</div>
@@ -85,7 +96,7 @@
<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)">
<a-tag class="plugin-status-tag" :class="{ 'is-loading': isActionLoading('toggle') }" :color="plugin.disabled ? 'default' : 'green'" @click.stop="togglePlugin">
{{ plugin.disabled ? t("certd.onlinePluginDisabled") : t("certd.onlinePluginEnabled") }}
</a-tag>
</a-tooltip>
@@ -95,7 +106,7 @@
</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)">
<a-tag class="plugin-status-tag" :class="{ 'is-loading': isActionLoading('toggle') }" :color="plugin.localDisabled ? 'default' : 'green'" @click.stop="togglePlugin">
{{ plugin.localDisabled ? t("certd.onlinePluginDisabled") : t("certd.onlinePluginEnabled") }}
</a-tag>
</a-tooltip>
@@ -108,23 +119,32 @@
</a-tag>
</a-tooltip>
</template>
<a-tooltip :title="versionTitle">
<a-tooltip v-if="!isBuiltInPlugin" :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>
<a-tooltip v-if="source !== 'local' && authorName" :title="plugin.selfAuthored ? '这是我自己提交的插件' : `${t('certd.author')}${authorName}`">
<span class="plugin-card__author" :class="{ 'is-self-authored': plugin.selfAuthored }">
<fs-icon :icon="plugin.selfAuthored ? 'ion:person-circle' : 'ion:person-circle-outline'" />
<span>{{ authorName }}</span>
</span>
</a-tooltip>
</div>
</a-card>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { computed, h, ref } from "vue";
import { message, Modal } from "ant-design-vue";
import { useFormDialog } from "/@/use/use-dialog";
import { useI18n } from "/src/locales";
import { usePluginStore } from "/@/store/plugin";
import * as api from "../api";
import { usePluginConfig } from "../use-config";
import { usePluginPublish } from "../use-publish";
import PluginEditDialogBody from "./plugin-edit-dialog-body.vue";
defineOptions({
name: "PluginItemCard",
@@ -137,29 +157,61 @@ const props = withDefaults(
current?: boolean;
simple?: boolean;
showConfig?: boolean;
installLoading?: boolean;
uninstallLoading?: boolean;
toggleLoading?: boolean;
editable?: boolean;
copyHandler?: (plugin: any) => void | Promise<void>;
}>(),
{
source: "market",
copyHandler: undefined,
}
);
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;
(e: "changed", payload: { plugin: any; action: PluginCardAction }): void;
}>();
const { t } = useI18n();
const pluginStore = usePluginStore();
const { openConfigDialog } = usePluginConfig();
const { isPublishingPlugin, publishLocalPlugin } = usePluginPublish();
const { openFormDialog } = useFormDialog();
const actionLoading = ref<PluginCardAction | "">("");
const editDialogBodyRef = ref();
type PluginCardAction = "edit" | "copy" | "export" | "publish" | "config" | "install" | "uninstall" | "remove" | "toggle";
const editPluginId = computed(() => {
return props.plugin.localPluginId || props.plugin.id;
});
const canEditPlugin = computed(() => {
if (props.editable != null) {
return props.editable;
}
return props.plugin.type === "custom";
});
const isOwnImportedPlugin = computed(() => {
return props.source === "local" && canEditPlugin.value;
});
const isAvailableLocally = computed(() => {
return props.source === "local" || !!props.plugin.installed;
});
const canCopyPlugin = computed(() => {
return isOwnImportedPlugin.value || (props.source === "market" && !!props.plugin.selfAuthored && isAvailableLocally.value);
});
const showEditButton = computed(() => {
return canEditPlugin.value && isAvailableLocally.value;
});
const showConfigButton = computed(() => {
return !!props.showConfig && isAvailableLocally.value;
});
const isInstalled = computed(() => {
return props.source !== "local" && props.plugin.installed;
@@ -170,7 +222,19 @@ const isDisabled = computed(() => {
});
const canRemoveLocal = computed(() => {
return props.plugin.type === "custom" || props.plugin.type === "store";
return isOwnImportedPlugin.value;
});
const isBuiltInPlugin = computed(() => {
return props.source === "local" && props.plugin.type === "builtIn";
});
const canPublishPlugin = computed(() => {
return canEditPlugin.value && isAvailableLocally.value && (!props.plugin.status || !!props.plugin.selfAuthored);
});
const showToolMenu = computed(() => {
return !isBuiltInPlugin.value && (isOwnImportedPlugin.value || canPublishPlugin.value);
});
const fullName = computed(() => {
@@ -201,6 +265,9 @@ const pluginTypeLabel = computed(() => {
});
const sourceLabel = computed(() => {
if (props.source === "local" && props.plugin.type !== "builtIn") {
return t("certd.localPlugin");
}
const labelMap: Record<string, string> = {
builtIn: t("certd.builtIn"),
custom: t("certd.custom"),
@@ -237,6 +304,201 @@ const toggleTitle = computed(() => {
return disabled ? t("certd.onlinePluginClickToEnable") : t("certd.onlinePluginClickToDisable");
});
function isActionLoading(action: PluginCardAction) {
return actionLoading.value === action;
}
function handleCardClick() {
if (props.source === "local") {
if (showEditButton.value) {
void editPlugin();
}
return;
}
emit("click", props.plugin);
}
function handleCardDoubleClick() {
if (props.source !== "local") {
emit("dblclick", props.plugin);
}
}
function emitChanged(action: PluginCardAction) {
emit("changed", {
plugin: props.plugin,
action,
});
}
async function runAction(action: PluginCardAction, callback: () => Promise<void>, options?: { emitChanged?: boolean }) {
if (actionLoading.value) {
return;
}
actionLoading.value = action;
try {
await callback();
if (options?.emitChanged !== false) {
emitChanged(action);
}
} finally {
actionLoading.value = "";
}
}
async function editPlugin() {
await openFormDialog({
title: `编辑插件 ${props.plugin.title || props.plugin.name}`,
columns: {},
noneForm: true,
wrapper: {
width: 1480,
destroyOnClose: true,
maskClosable: false,
okText: "保存",
cancelText: "关闭",
class: "plugin-edit-dialog",
},
body: () =>
h(PluginEditDialogBody, {
ref: editDialogBodyRef,
pluginId: editPluginId.value,
}),
async onSubmit() {
await editDialogBodyRef.value?.save?.();
emitChanged("edit");
},
});
}
async function copyPlugin() {
if (!props.copyHandler) {
return;
}
await runAction(
"copy",
async () => {
await props.copyHandler?.(props.plugin);
},
{ emitChanged: true }
);
}
async function exportPlugin() {
await runAction(
"export",
async () => {
const content = await api.ExportPlugin(props.plugin.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 = `${props.plugin.name}.yaml`;
link.click();
URL.revokeObjectURL(url);
},
{ emitChanged: false }
);
}
async function publishPlugin() {
await publishLocalPlugin(
{ ...props.plugin, id: editPluginId.value },
{
async afterPublish() {
emitChanged("publish");
},
}
);
}
async function openConfig() {
await openConfigDialog({
row: props.plugin,
onSuccess: async () => {
emitChanged("config");
},
});
}
async function installPlugin() {
const fullName = fullNameValue();
if (!fullName) {
return;
}
await runAction("install", async () => {
await api.OnlinePluginInstall({
fullName,
version: props.plugin.latest,
});
message.success(t("certd.onlinePluginInstallSuccess"));
});
}
function uninstallPlugin() {
if (!props.plugin.localPluginId) {
return;
}
Modal.confirm({
title: t("certd.confirm"),
content: t("certd.onlinePluginDeleteConfirm", { name: fullNameValue() || props.plugin.title || props.plugin.name }),
async onOk() {
await runAction("uninstall", async () => {
await api.DelObj(props.plugin.localPluginId);
message.success(t("certd.onlinePluginUninstallSuccess"));
});
},
});
}
function removePlugin() {
if (!props.plugin.id) {
return;
}
Modal.confirm({
title: t("certd.confirm"),
content: "确定要删除吗?如果该插件已被使用,删除可能会导致流水线执行失败!",
async onOk() {
await runAction("remove", async () => {
await api.DelObj(props.plugin.id);
message.success(t("certd.onlinePluginUninstallSuccess"));
});
},
});
}
function togglePlugin() {
const id = props.source === "local" ? props.plugin.id : props.plugin.localPluginId;
const canToggleBuiltInPlugin = props.source === "local" && props.plugin.type === "builtIn" && !!props.plugin.name;
if (!id && !canToggleBuiltInPlugin) {
return;
}
Modal.confirm({
title: t("certd.confirm"),
content: `${t("certd.confirmToggle")} ${isDisabled.value ? t("certd.enable") : t("certd.disable")}?`,
maskClosable: true,
async onOk() {
await runAction("toggle", async () => {
await api.SetDisabled({
id: id || undefined,
name: props.plugin.name,
type: props.source === "local" ? props.plugin.type : "store",
disabled: !isDisabled.value,
});
await pluginStore.reload();
message.success(t("certd.operationSuccess"));
});
},
});
}
function fullNameValue() {
return fullName.value;
}
function formatDownloadCount(count: number) {
if (count >= 10000) {
return `${(count / 10000).toFixed(1).replace(/\.0$/, "")}w`;
@@ -248,7 +510,15 @@ function handleVersionClick() {
if (props.source === "local" || !props.plugin.upgradeAvailable) {
return;
}
emit("install", props.plugin);
Modal.confirm({
title: "升级插件",
content: `确认将 ${props.plugin.title || props.plugin.name} 升级到 v${props.plugin.latest} 吗?`,
okText: "确认升级",
cancelText: "取消",
async onOk() {
await installPlugin();
},
});
}
</script>
@@ -304,7 +574,7 @@ function handleVersionClick() {
.plugin-card__head {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@@ -313,17 +583,17 @@ function handleVersionClick() {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-start;
align-items: center;
}
.plugin-card__icon {
flex: none;
margin-top: 1px;
margin-top: 0;
}
.plugin-card__main .plugin-icon {
display: flex;
align-items: flex-end;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
@@ -334,14 +604,19 @@ function handleVersionClick() {
}
.plugin-card__title-wrap {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 4px;
margin-left: 8px;
}
.plugin-card__title {
display: block;
min-width: 0;
flex: 0 1 auto;
max-width: 100%;
overflow: hidden;
color: #1f2937;
font-size: 14px;
@@ -378,6 +653,14 @@ function handleVersionClick() {
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-self-authored {
color: #1677ff;
.fs-icon {
color: #1677ff;
}
}
}
.plugin-card__actions {
@@ -392,14 +675,9 @@ function handleVersionClick() {
.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 {
@@ -417,7 +695,14 @@ function handleVersionClick() {
transition-timing-function: ease-out;
.fs-icon {
display: flex;
width: 16px;
height: 16px;
flex: none;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
}
&:hover {
@@ -482,7 +767,14 @@ function handleVersionClick() {
}
.plugin-card__local-action-zone {
width: 76px;
width: 28px;
.plugin-card__action-button {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.plugin-card__install-zone {
@@ -496,12 +788,6 @@ function handleVersionClick() {
}
}
&:hover {
.plugin-card__tools {
opacity: 1;
}
}
.plugin-card__desc {
display: -webkit-box;
min-height: 40px;
@@ -570,6 +856,13 @@ function handleVersionClick() {
}
}
.plugin-card__ai-check-icon {
flex: none;
color: #52c41a;
font-size: 16px;
line-height: 1;
}
&.is-simple {
.plugin-card__desc {
min-height: 40px;
@@ -1,334 +0,0 @@
<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>
@@ -114,7 +114,12 @@ const { openConfigDialog } = usePluginConfig();
async function doPluginConfig() {
const certApplyInfo = await GetPluginByName("CertApply");
await openConfigDialog({ row: certApplyInfo, crudExpose: null });
await openConfigDialog({
row: certApplyInfo,
onSuccess: async () => {
await loadForm();
},
});
}
</script>
<style lang="less"></style>
@@ -49,6 +49,9 @@ import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import Rollbackable from "./rollbackable.vue";
import { usePluginStore } from "/@/store/plugin";
import * as api from "./api";
// @ts-ignore js-yaml 没有在当前前端包中提供类型声明。
import yaml from "js-yaml";
const route = useRoute();
const router = useRouter();
const pluginStore = usePluginStore();
@@ -264,12 +267,35 @@ function clearFormValue(key: string) {
console.log(key, configForm);
}
function getPluginOriginName() {
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;
}
async function loadPluginSetting() {
currentPlugin.value = await pluginStore.getPluginDefineFromOrigin(props.plugin.name);
for (const key in currentPlugin.value.input) {
const originName = getPluginOriginName();
currentPlugin.value = await pluginStore.getPluginDefineFromOrigin(originName);
if (!currentPlugin.value?.input && originName !== props.plugin.name) {
currentPlugin.value = await pluginStore.getPluginDefineFromOrigin(props.plugin.name);
}
if (!currentPlugin.value?.input && props.plugin.id) {
const plugin = await api.GetObj(props.plugin.localPluginId || props.plugin.id);
const metadata = plugin.metadata ? yaml.load(plugin.metadata) : {};
currentPlugin.value = {
...plugin,
...(metadata || {}),
};
}
const input = currentPlugin.value?.input || {};
for (const key in input) {
configForm[key] = {};
}
const setting = props.plugin.sysSetting;
const setting = currentPlugin.value?.sysSetting || props.plugin.sysSetting;
if (setting) {
const settingJson = JSON.parse(setting);
merge(configForm, settingJson.metadata?.input || {});
@@ -1,7 +1,6 @@
import * as api from "./api";
import { useI18n } from "/src/locales";
import { Ref, ref, computed } from "vue";
import { useRouter } from "vue-router";
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { Modal, message } from "ant-design-vue";
//@ts-ignore
@@ -11,9 +10,9 @@ import KvInput from "/@/components/plugins/common/kv-input.vue";
import { usePluginConfig } from "./use-config";
import { useSettingStore } from "/src/store/settings/index";
import { usePluginStore } from "/@/store/plugin";
import PluginAuthorField from "./components/plugin-author-field.vue";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const router = useRouter();
const { t } = useI18n();
let lastType = "";
@@ -50,6 +49,70 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const settingStore = useSettingStore();
const pluginStore = usePluginStore();
const syncLoading = ref(false);
const lastSyncTime = ref(0);
const autoSyncInterval = 30 * 24 * 60 * 60 * 1000;
const syncButtonTitle = computed(() => {
if (!lastSyncTime.value) {
return t("certd.onlinePluginNotSynced");
}
return t("certd.onlinePluginLastSyncTime", {
time: formatSyncTime(lastSyncTime.value),
});
});
function formatSyncTime(time: number) {
return new Date(time).toLocaleString();
}
function needAutoSync(time: number) {
if (!time) {
return true;
}
return Date.now() - time > autoSyncInterval;
}
function canEditStorePlugin(row: any) {
if (row.type !== "store") {
return false;
}
if (typeof row.localEditable === "boolean") {
return row.localEditable;
}
const bindUserId = Number(settingStore.installInfo?.bindUserId || 0);
return !row.developerId || (!!bindUserId && Number(row.developerId) === bindUserId);
}
async function syncOnlinePlugins(options?: { showSuccess?: boolean }) {
if (syncLoading.value) {
return;
}
syncLoading.value = true;
try {
await api.OnlinePluginSync();
const setting = await api.OnlinePluginSetting();
lastSyncTime.value = setting.lastSyncTime || Date.now();
await pluginStore.reload();
crudExpose.doRefresh();
if (options?.showSuccess !== false) {
message.success(t("certd.onlinePluginSyncSuccess"));
}
} finally {
syncLoading.value = false;
}
}
async function loadOnlinePluginSetting() {
const setting = await api.OnlinePluginSetting();
lastSyncTime.value = setting.lastSyncTime || 0;
if (needAutoSync(lastSyncTime.value)) {
await syncOnlinePlugins({ showSuccess: false });
}
}
loadOnlinePluginSetting().catch(e => {
console.warn("load online plugin setting failed", e);
});
return {
crudOptions: {
settings: {
@@ -89,6 +152,17 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
await openImportDialog({ crudExpose });
},
},
syncOnline: {
show: true,
icon: "ion:sync-outline",
type: "primary",
text: t("certd.onlinePluginSync"),
tooltip: { title: syncButtonTitle },
loading: syncLoading,
async click() {
await syncOnlinePlugins();
},
},
clearRuntimeDeps: {
show: true,
icon: "ion:trash-outline",
@@ -126,13 +200,21 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
buttons: {
edit: {
show: compute(({ row }) => {
return row.type === "custom";
return canEditStorePlugin(row);
}),
},
copy: {
show: compute(({ row }) => {
return row.type === "custom";
return canEditStorePlugin(row);
}),
async click({ row }) {
const copyRow = { ...row };
delete copyRow.fullName;
delete copyRow.id;
crudExpose.openCopy({
row: copyRow,
});
},
},
remove: {
order: 999,
@@ -146,7 +228,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
title: t("certd.export"),
type: "link",
show: compute(({ row }) => {
return row.type === "custom";
return canEditStorePlugin(row);
}),
async click({ row }) {
const content = await api.ExportPlugin(row.id);
@@ -172,7 +254,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
async click({ row }) {
await openConfigDialog({
row,
crudExpose,
onSuccess: async () => {
crudExpose.doRefresh();
},
});
},
},
@@ -187,16 +271,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
form: {
onSuccess(opts: any) {
if (opts.res?.id) {
router.push({
name: "SysPluginEdit",
query: {
id: opts.res.id,
},
});
} else {
crudExpose.doRefresh();
}
crudExpose.doRefresh();
},
},
columns: {
@@ -287,19 +362,18 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
title: t("certd.author"),
type: "text",
search: {
component: {
name: "a-input",
vModel: "value",
},
show: true,
},
form: {
show: true,
helper: t("certd.authorHelper"),
rules: [
{ required: true },
{
type: "pattern",
pattern: /^[a-zA-Z][a-zA-Z0-9]+$/,
message: t("certd.authorRuleMsg"),
},
],
component: {
name: PluginAuthorField,
vModel: "modelValue",
},
},
column: {
width: 200,
@@ -316,9 +390,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
column: {
width: 300,
cellRender({ row }) {
if (row.type === "custom") {
return <router-link to={`/sys/plugin/edit?id=${row.id}`}>{row.title}</router-link>;
}
return <div>{row.title}</div>;
},
},
@@ -339,7 +410,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: true,
},
form: {
value: "custom",
value: "store",
component: {
disabled: true,
},
@@ -347,8 +418,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
dict: dict({
data: [
{ label: t("certd.builtIn"), value: "builtIn" },
{ label: t("certd.custom"), value: "custom" },
{ label: t("certd.installedStorePlugin"), value: "store" },
{ label: t("certd.store"), value: "store" },
],
}),
column: {
@@ -473,6 +543,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
Modal.confirm({
title: t("certd.confirm"),
content: `${t("certd.confirmToggle")} ${!value ? t("certd.disable") : t("certd.enable")}?`,
maskClosable: true,
onOk: async () => {
await api.SetDisabled({
id: row.id,
@@ -480,7 +551,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
type: row.type,
disabled: !value,
});
await crudExpose.doRefresh();
crudExpose.doRefresh();
},
});
},
@@ -491,6 +562,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
group: {
title: t("certd.pluginGroup"),
type: "dict-select",
search: {
show: true,
},
dict: dict({
url: "/pi/plugin/groupsList",
label: "title",
@@ -1,230 +0,0 @@
<template>
<fs-page class="page-plugin-edit">
<template #header>
<div class="title">
插件编辑
<span class="sub">
<span class="name flex-inline"> 插件名称<fs-copyable :model-value="pluginName"></fs-copyable> </span>
</span>
</div>
<div class="more">
<a-button class="mr-1" type="primary" :loading="saveLoading" @click="doSave">保存</a-button>
</div>
</template>
<div class="pi-plugin-editor">
<div class="base">
<a-tabs type="card">
<a-tab-pane key="base" tab="插件信息"> </a-tab-pane>
</a-tabs>
<div class="base-body">
<fs-form ref="baseFormRef" v-bind="formOptionsRef"></fs-form>
</div>
</div>
<div class="metadata">
<a-tabs type="card">
<a-tab-pane key="editor" tab="元数据"> </a-tab-pane>
</a-tabs>
<div class="metadata-body">
<code-editor :id="`metadata_${idRef}`" v-model:model-value="plugin.metadata" language="yaml" @save="doSave"></code-editor>
</div>
</div>
<div class="script">
<a-tabs type="card">
<a-tab-pane key="script" tab="脚本"> </a-tab-pane>
</a-tabs>
<div class="script-body">
<code-editor :id="`content_${idRef}`" v-model:model-value="plugin.content" language="javascript" @save="doSave"></code-editor>
</div>
</div>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { onMounted, provide, ref, Ref, computed } from "vue";
import { useRoute } from "vue-router";
import * as api from "./api";
import { notification } from "ant-design-vue";
import createCrudOptions from "./crud";
import { useColumns } from "@fast-crud/fast-crud";
import { usePluginStore } from "/@/store/plugin";
//@ts-ignore
import yaml from "js-yaml";
defineOptions({
name: "SysPluginEdit",
});
const route = useRoute();
const pluginStore = usePluginStore();
const plugin = ref<any>({});
const formOptionsRef: Ref = ref();
const baseFormRef: Ref = ref({});
function initFormOptions() {
const formCrudOptions = createCrudOptions({
//@ts-ignore
crudExpose: {},
context: {},
});
const { buildFormOptions } = useColumns();
const formOptions = buildFormOptions(formCrudOptions.crudOptions, {});
formOptions.mode = "edit";
formOptions.col = {
span: 24,
};
formOptions.labelCol = {
style: {
width: "100px",
},
};
formOptionsRef.value = formOptions;
}
initFormOptions();
const idRef = ref(route.query.id);
async function getPlugin() {
const id = route.query.id;
const pluginObj = await api.GetObj(id);
plugin.value = pluginObj;
const baseFrom = {
...pluginObj,
};
if (baseFrom.extra) {
baseFrom.extra = yaml.load(baseFrom.extra);
}
delete baseFrom.metadata;
delete baseFrom.content;
baseFormRef.value.setFormData(baseFrom);
}
onMounted(async () => {
getPlugin();
});
const pluginName = computed(() => {
if (!plugin.value) {
return "";
}
if (plugin.value.author) {
return `${plugin.value.author}/${plugin.value.name}`;
}
return plugin.value.name;
});
provide("get:plugin", () => {
return plugin;
});
function validate() {
try {
yaml.load(plugin.value.metadata);
} catch (e: any) {
const message = `元数据校验失败:${e.message}`;
notification.error({
message,
});
throw new Error(message);
}
}
const saveLoading = ref(false);
async function doSave() {
validate();
saveLoading.value = true;
const baseForm = baseFormRef.value.getFormData();
const form = {
...plugin.value,
...baseForm,
};
if (form.extra) {
form.extra = yaml.dump(form.extra);
}
try {
await api.UpdateObj(form);
notification.success({
message: "保存成功",
});
pluginStore.clear();
} finally {
saveLoading.value = false;
}
}
async function doTest() {
await doSave();
const result = await api.DoTest({
id: plugin.value.id,
input: {},
});
notification.success({
message: "测试已开始",
description: result,
});
}
</script>
<style lang="less">
.page-plugin-edit {
.pi-plugin-editor {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
padding: 20px;
.fs-editor-code {
height: 100%;
flex: 1;
}
.base {
width: 400px;
max-width: 30%;
margin-right: 20px;
display: flex;
flex-direction: column;
height: 100%;
.base-body {
flex: 1;
overflow: auto;
}
}
.metadata {
width: 600px;
max-width: 30%;
margin-right: 20px;
display: flex;
flex-direction: column;
height: 100%;
.metadata-body {
height: 100%;
flex: 1;
}
.metadata-editor {
height: 100%;
flex: 1;
.ant-tabs-content {
height: 100%;
}
}
.metadata-source {
height: 100%;
}
}
.script {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.script-body {
flex: 1;
}
}
}
}
</style>
@@ -6,134 +6,108 @@
<span class="sub">{{ t("certd.pluginBetaWarning") }}</span>
</div>
</template>
<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-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 of pluginList"
:key="item.id || item.name"
:source="getPluginCardSource(item)"
:plugin="item"
:show-config="settingStore.isComm"
:editable="canEditPlugin(item)"
:copy-handler="copyPlugin"
@changed="handlePluginChanged"
@click="openPluginDetail"
/>
</div>
</fs-crud>
<OnlinePluginDetailModal v-model:open="detailVisible" :plugin="detailPlugin" @installed="handleDetailInstalled" />
</fs-page>
</template>
<script lang="ts" setup>
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
import { message, Modal } from "ant-design-vue";
import * as api from "./api";
import PluginMarketPanel from "./components/plugin-market-panel.vue";
import PluginItemCard from "./components/plugin-item-card.vue";
import OnlinePluginDetailModal from "./components/online-plugin-detail-modal.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";
import { usePluginStore } from "/@/store/plugin";
const { t } = useI18n();
const pluginStore = usePluginStore();
const settingStore = useSettingStore();
const { openConfigDialog } = usePluginConfig();
const router = useRouter();
const pluginStore = usePluginStore();
defineOptions({
name: "SysPlugin",
});
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
const activeTab = ref("local");
const detailVisible = ref(false);
const detailPlugin = ref<any>();
const pluginList = computed(() => {
return crudBinding.value?.data || [];
});
function openEditPage(row: any) {
router.push(`/sys/plugin/edit?id=${row.id}`);
function getPluginCardSource(row: any) {
return row.type === "store" && (row.appId || row.developerId) ? "market" : "local";
}
async function openCopy(opts: any) {
await crudExpose.openCopy({
row: {
...opts.row,
},
index: opts.index,
});
function canEditPlugin(row: any) {
if (row.type === "custom") {
return true;
}
if (row.type !== "store") {
return false;
}
if (typeof row.localEditable === "boolean") {
return row.localEditable;
}
const bindUserId = Number(settingStore.installInfo?.bindUserId || 0);
return !row.developerId || (!!bindUserId && Number(row.developerId) === bindUserId);
}
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) {
function openPluginDetail(row: any) {
if (row.type !== "store" || !(row.fullName || (row.author && row.name))) {
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);
detailPlugin.value = row;
detailVisible.value = true;
}
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"));
async function handleDetailInstalled() {
crudExpose.doRefresh();
}
async function copyPlugin(row: any) {
const copyRow = { ...row };
delete copyRow.fullName;
delete copyRow.id;
delete copyRow.developerId;
delete copyRow.appId;
delete copyRow.latest;
delete copyRow.status;
delete copyRow.downloadCount;
delete copyRow.score;
await crudExpose.openCopy(
{
row: copyRow,
},
});
{
async onSuccess() {
crudExpose.doRefresh();
},
}
);
}
function handleMarketPluginChanged() {
async function handlePluginChanged(payload: { action: string }) {
if (payload.action === "install" || payload.action === "uninstall" || payload.action === "remove" || payload.action === "copy") {
await pluginStore.reload();
}
crudExpose.doRefresh();
}
@@ -158,55 +132,6 @@ useMounted(async () => {
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;
}
@@ -12,7 +12,7 @@ export function usePluginConfig() {
const pluginStore = usePluginStore();
// @ts-ignore
async function openConfigDialog({ row, crudExpose }) {
async function openConfigDialog({ row, onSuccess }) {
const configEditorRef = ref();
function createCrudOptions() {
return {
@@ -33,10 +33,10 @@ export function usePluginConfig() {
},
},
},
afterSubmit() {
async afterSubmit() {
notification.success({ message: t("certd.operationSuccess") });
if (crudExpose) {
crudExpose.doRefresh();
if (onSuccess) {
await onSuccess();
}
},
async doSubmit({}: any) {
@@ -66,6 +66,7 @@ export function usePluginImport() {
async doSubmit({ form }: any) {
return await api.ImportPlugin({
...form,
type: "store",
});
},
},
@@ -0,0 +1,136 @@
.sys-plugin-publish {
margin: 2px;
&__hero {
display: flex;
gap: 16px;
padding: 18px;
border: 1px solid #e4ecf7;
border-radius: 10px;
background: linear-gradient(135deg, #f7fbff 0%, #eef5ff 55%, #f8fbff 100%);
}
&__icon-wrap {
display: flex;
width: 54px;
height: 54px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid rgba(64, 120, 192, 0.16);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 22px rgba(31, 77, 132, 0.08);
}
&__icon {
color: #2f6fbb;
font-size: 30px;
}
&__hero-main {
min-width: 0;
flex: 1;
}
&__title-line,
&__version-line,
&__author-register,
&__review-icons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
&__title-line {
gap: 10px;
}
&__title {
color: #172033;
font-size: 18px;
font-weight: 700;
word-break: break-all;
}
&__desc {
margin-top: 4px;
color: #526172;
line-height: 22px;
word-break: break-all;
}
&__sections,
&__latest-version {
display: grid;
gap: 12px;
}
&__sections {
margin-top: 16px;
}
&__latest-version {
gap: 10px;
}
&__meta-full {
grid-column: 1 / -1;
}
&__version {
color: #1f2937;
font-variant-numeric: tabular-nums;
}
&__review-icons {
gap: 6px;
}
&__review-icon {
display: block;
flex: 0 0 auto;
font-size: 16px;
line-height: 1;
&.is-ai {
&.is-passed {
color: #52c41a;
}
&.is-pending {
color: #faad14;
}
&.is-rejected {
color: #ff4d4f;
}
}
&.is-admin {
color: #1677ff;
&.is-rejected {
color: #ff4d4f;
}
}
}
&__rejected-reasons {
display: grid;
gap: 4px;
padding: 8px 10px;
border-left: 3px solid #ff7875;
border-radius: 4px;
background: #fff2f0;
color: #a61d24;
font-size: 12px;
line-height: 19px;
word-break: break-word;
}
&__prompt {
margin-bottom: 12px;
}
}
@@ -0,0 +1,511 @@
import { reactive, ref } from "vue";
import { message } from "ant-design-vue";
import { useFormDialog } from "/@/use/use-dialog";
import { useI18n } from "/src/locales";
import { usePluginStore } from "/@/store/plugin";
import * as api from "./api";
import "./use-publish.less";
type PublishManagerResult =
| {
action: "publish";
version: string;
}
| {
action: "registered";
}
| {
action: "cancel";
};
export function usePluginPublish() {
const { t } = useI18n();
const { openFormDialog } = useFormDialog();
const pluginStore = usePluginStore();
const publishingPluginId = ref<number | string>("");
const authorNamePattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
function getPluginTypeLabel(pluginType: string) {
const labelMap: Record<string, string> = {
access: t("certd.auth"),
dnsProvider: t("certd.dns"),
deploy: t("certd.deployPlugin"),
};
return labelMap[pluginType] || pluginType || "-";
}
function isPublishingPlugin(row: any) {
return !!row?.id && publishingPluginId.value === row.id;
}
function registerPluginAuthor() {
return new Promise<api.OnlinePluginAuthorBean | undefined>(resolve => {
let resolved = false;
const resolveAuthor = (author?: api.OnlinePluginAuthorBean) => {
if (resolved) {
return;
}
resolved = true;
resolve(author);
};
void openFormDialog({
title: t("certd.onlinePluginAuthorRegister"),
wrapper: {
width: 560,
onClosed() {
resolveAuthor();
},
},
initialForm: {
name: "",
nameConfirm: "",
desc: "",
},
async onSubmit(form: any) {
if (form.name?.trim() !== form.nameConfirm?.trim()) {
throw new Error("两次输入的作者名称不一致");
}
const author = await api.OnlinePluginAuthorAdd({
name: form.name,
displayName: form.displayName,
desc: form.desc,
});
resolveAuthor(author);
},
columns: {
name: {
title: t("certd.onlinePluginAuthorName"),
type: "text",
form: {
col: { span: 24 },
helper: t("certd.onlinePluginAuthorNameHelper"),
rules: [
{ required: true, message: t("certd.onlinePluginAuthorNameRequired") },
{
pattern: authorNamePattern,
message: t("certd.onlinePluginAuthorNameRuleMsg"),
},
],
},
},
nameConfirm: {
title: "确认作者名称",
type: "text",
form: {
col: { span: 24 },
helper: "请再次输入相同名称。作者名称注册后不允许修改。",
rules: [{ required: true, message: "请再次输入作者名称" }],
},
},
},
});
});
}
function getStatusLabel(status?: string) {
const labelMap: Record<string, string> = {
draft: t("certd.onlinePluginStatusDraft"),
reviewing: t("certd.onlinePluginStatusReviewing"),
published: t("certd.onlinePluginStatusPublished"),
rejected: t("certd.onlinePluginStatusRejected"),
offline: t("certd.onlinePluginStatusOffline"),
};
return labelMap[status || ""] || status || "-";
}
function normalizeVersion(version?: string) {
return (version || "").trim();
}
function isReviewingVersion(version: api.OnlinePluginVersionBean) {
return version.status === "reviewing" || version.reviewStatus === "ai_pending" || version.reviewStatus === "pending";
}
function compareVersion(current: string, last: string) {
const currentParts = normalizeVersion(current)
.replace(/^v/i, "")
.split(".")
.map(item => Number.parseInt(item, 10));
const lastParts = normalizeVersion(last)
.replace(/^v/i, "")
.split(".")
.map(item => Number.parseInt(item, 10));
const maxLength = Math.max(currentParts.length, lastParts.length);
for (let index = 0; index < maxLength; index++) {
const currentPart = currentParts[index] || 0;
const lastPart = lastParts[index] || 0;
if (currentPart > lastPart) {
return 1;
}
if (currentPart < lastPart) {
return -1;
}
}
return 0;
}
function findLatestPublishedVersion(versions: api.OnlinePluginVersionBean[], latest?: string) {
const publishedVersions = versions.filter(item => item.status === "published");
const latestText = normalizeVersion(latest);
if (latestText) {
const matched = publishedVersions.find(item => normalizeVersion(item.version) === latestText);
if (matched) {
return matched;
}
}
return publishedVersions[0];
}
function getDefaultPublishVersion(row: any, localPlugin: any, reviewingVersion?: api.OnlinePluginVersionBean) {
const localVersion = normalizeVersion(row.version || localPlugin.version);
if (reviewingVersion) {
return localVersion || normalizeVersion(reviewingVersion.version);
}
return localVersion || "1.0.0";
}
function getVersionError(version: string, baselineVersion?: api.OnlinePluginVersionBean) {
const value = normalizeVersion(version);
if (!value) {
return t("certd.onlinePluginPublishVersionRequired");
}
if (!/^v?\d+(\.\d+)*$/i.test(value)) {
return t("certd.onlinePluginVersionFormatError");
}
if (baselineVersion?.version && compareVersion(value, baselineVersion.version) <= 0) {
return t("certd.onlinePluginVersionBaselineError", { version: baselineVersion.version });
}
return "";
}
function getStatusColor(status?: string) {
const colorMap: Record<string, string> = {
draft: "default",
reviewing: "processing",
published: "success",
rejected: "error",
offline: "warning",
};
return colorMap[status || ""] || "default";
}
function getAiCheckIcon(version: api.OnlinePluginVersionBean) {
if (version.aiCheckStatus === "rejected") {
return "lucide:shield-x";
}
if (version.aiCheckStatus === "pending") {
return "lucide:shield-alert";
}
return "lucide:shield-check";
}
function getAiCheckTooltip(version: api.OnlinePluginVersionBean) {
const result = `${version.aiCheckResult || ""}`.trim();
if (version.aiCheckStatus === "rejected") {
return result ? `AI 安全审查未通过:${result}` : "AI 安全审查未通过";
}
if (version.aiCheckStatus === "pending" || version.reviewStatus === "ai_pending") {
return result ? `AI 安全审查中:${result}` : "AI 安全审查中";
}
return result ? `AI 安全审查已通过:${result}` : "AI 安全审查已通过";
}
function isAdminRejected(version: api.OnlinePluginVersionBean) {
return version.reviewStatus === "rejected" && !!`${version.reviewReason || ""}`.trim();
}
function hasAdminReview(version: api.OnlinePluginVersionBean) {
return version.reviewStatus === "passed" || isAdminRejected(version);
}
function getAdminReviewTooltip(version: api.OnlinePluginVersionBean) {
if (isAdminRejected(version)) {
return `管理员审核已拒绝:${version.reviewReason}`;
}
return "管理员审核已通过";
}
function renderReviewIndicators(version: api.OnlinePluginVersionBean) {
const aiCheckStatus = version.aiCheckStatus || (version.reviewStatus === "ai_pending" ? "pending" : "");
return (
<span class="sys-plugin-publish__review-icons">
{aiCheckStatus ? (
<a-tooltip title={getAiCheckTooltip(version)}>
<fs-icon class={["sys-plugin-publish__review-icon", "is-ai", `is-${aiCheckStatus}`]} icon={getAiCheckIcon({ ...version, aiCheckStatus })} />
</a-tooltip>
) : null}
{hasAdminReview(version) ? (
<a-tooltip title={getAdminReviewTooltip(version)}>
<fs-icon class={["sys-plugin-publish__review-icon", "is-admin", { "is-rejected": isAdminRejected(version) }]} icon={isAdminRejected(version) ? "lucide:user-x" : "lucide:user-check"} />
</a-tooltip>
) : null}
</span>
);
}
function renderRejectedReasons(version: api.OnlinePluginVersionBean) {
const aiReason = version.aiCheckStatus === "rejected" ? `${version.aiCheckResult || ""}`.trim() : "";
if (!aiReason) {
return null;
}
return (
<div class="sys-plugin-publish__rejected-reasons">
<div class="sys-plugin-publish__rejected-reason is-ai">AI {aiReason}</div>
</div>
);
}
function openPublishManager(row: any, info: Awaited<ReturnType<typeof api.OnlinePluginPublishInfo>>) {
const author = info.author;
const marketPlugin = info.marketPlugin;
const versions = info.versions || [];
const localPlugin = info.localPlugin || row;
const reviewingVersion = versions.find(isReviewingVersion);
const rejectedVersion = versions.find(item => item.status === "rejected" || item.reviewStatus === "rejected");
const replaceableVersion = reviewingVersion || rejectedVersion;
const latestPublished = findLatestPublishedVersion(versions, marketPlugin?.latest);
const latestVersion = versions[0];
const isFirstPublish = !marketPlugin && versions.length === 0;
const publishMode = isFirstPublish ? "first" : replaceableVersion ? "cover" : "new";
const defaultVersion = getDefaultPublishVersion(row, localPlugin, replaceableVersion);
const versionBaseline = publishMode === "cover" ? latestPublished : latestVersion;
const state = reactive({
version: defaultVersion,
error: getVersionError(defaultVersion, versionBaseline),
});
let formWrapper: any;
let resolved = false;
let resolveResult: (result: PublishManagerResult) => void;
function getPublishModeTitle() {
if (publishMode === "first") {
return "首次发布";
}
if (publishMode === "cover") {
return "覆盖待审核版本";
}
return "发布新版本";
}
function getPublishModeTip() {
if (publishMode === "first") {
return "插件还没有提交到在线市场,确认后会创建插件并提交发布审核。";
}
if (publishMode === "cover") {
if (rejectedVersion && rejectedVersion === replaceableVersion) {
return `当前 v${rejectedVersion.version || "-"} 已被拒绝,本次提交可覆盖该版本并重新提交审核。`;
}
return `当前已有 v${reviewingVersion?.version || "-"} 正在审核中,本次提交会撤回并覆盖该审核版本。`;
}
return `当前最新版本为 v${latestVersion?.version || latestPublished?.version || marketPlugin?.latest || "-"},新提交的版本号必须更高。`;
}
function renderMetaItem(label: string, value: any) {
return (
<div class="cd-meta-item">
<span class="cd-meta-label">{label}</span>
<span class="cd-meta-value">{value || "-"}</span>
</div>
);
}
function renderAuthor() {
if (info.authorRegistered) {
return author?.displayName || author?.name || "-";
}
return (
<span class="sys-plugin-publish__author-register">
<span class="cd-text-danger">{t("certd.onlinePluginAuthorNotRegistered")}</span>
<a-button
size="small"
type="link"
onClick={async (event: any) => {
event.stopPropagation();
const createdAuthor = await registerPluginAuthor();
if (!createdAuthor?.id) {
return;
}
resolveResult({ action: "registered" });
formWrapper?.close?.();
}}
>
{t("certd.onlinePluginAuthorRegister")}
</a-button>
</span>
);
}
function renderLatestVersion() {
if (!latestVersion) {
return <div class="cd-text-muted">{t("certd.onlinePluginNoSubmittedVersion")}</div>;
}
return (
<div class="sys-plugin-publish__latest-version">
<div class="sys-plugin-publish__version-line">
<strong class="sys-plugin-publish__version">v{latestVersion.version || "-"}</strong>
<a-tag color={getStatusColor(latestVersion.status)}>{getStatusLabel(latestVersion.status)}</a-tag>
{renderReviewIndicators(latestVersion)}
</div>
{renderRejectedReasons(latestVersion)}
</div>
);
}
function renderContent() {
return (
<div class="sys-plugin-publish">
<div class="sys-plugin-publish__hero">
<div class="sys-plugin-publish__icon-wrap">
<fs-icon class="sys-plugin-publish__icon" icon={localPlugin.icon || row.icon || "clarity:plugin-line"} />
</div>
<div class="sys-plugin-publish__hero-main">
<div class="sys-plugin-publish__title-line">
<div class="sys-plugin-publish__title">{localPlugin.title || row.title || localPlugin.name || row.name || "-"}</div>
<a-tag color="blue">{getPublishModeTitle()}</a-tag>
</div>
<div class="sys-plugin-publish__desc">{localPlugin.desc || row.desc || "暂无描述"}</div>
</div>
</div>
<div class="sys-plugin-publish__sections">
<div class="cd-card-section">
<div class="cd-card-section-title"></div>
<div class="cd-meta-grid">
{renderMetaItem(t("certd.pluginName"), localPlugin.name || row.name)}
{renderMetaItem(t("certd.pluginType"), getPluginTypeLabel(localPlugin.pluginType || row.pluginType))}
{renderMetaItem(t("certd.pluginGroup"), localPlugin.group || row.group)}
{renderMetaItem(t("certd.onlinePluginPublishStatus"), marketPlugin ? getStatusLabel(marketPlugin.status) : t("certd.onlinePluginNotSubmitted"))}
<div class="sys-plugin-publish__meta-full">{renderMetaItem(t("certd.author"), renderAuthor())}</div>
</div>
</div>
<div class="cd-card-section">
<div class="cd-card-section-title">{t("certd.onlinePluginLatestVersion")}</div>
{renderLatestVersion()}
</div>
<div class="cd-card-section">
<div class="cd-card-section-title cd-card-section-title--compact">{t("certd.onlinePluginCurrentRelease")}</div>
<div class={["cd-tip-box sys-plugin-publish__prompt", { "cd-tip-box-warning": publishMode === "cover" }]}>
<div class="cd-tip-title">{t("certd.onlinePluginPublishPrompt")}</div>
<div>{getPublishModeTip()}</div>
</div>
<div class="cd-form-row">
<label class="cd-form-label">{t("certd.version")}</label>
<div>
<input
type="text"
value={state.version}
placeholder="请输入版本号,例如 1.0.1"
class={["cd-text-input", { "cd-text-input-error": state.error }]}
onInput={(event: any) => {
state.version = event?.target?.value || "";
state.error = getVersionError(state.version, versionBaseline);
}}
/>
{state.error ? <div class="cd-field-error">{state.error}</div> : null}
</div>
</div>
</div>
</div>
</div>
);
}
return new Promise<PublishManagerResult>(resolve => {
resolveResult = (result: PublishManagerResult) => {
if (resolved) {
return;
}
resolved = true;
resolve(result);
};
void openFormDialog({
title: marketPlugin ? t("certd.onlinePluginPublishManage") : t("certd.onlinePluginPublish"),
columns: {},
body: () => renderContent(),
wrapper: {
width: 920,
buttons: {
reset: {
show: false,
},
ok: {
show: true,
text: t("certd.onlinePluginPublishSubmit"),
disabled: !info.authorRegistered,
},
cancel: {
show: true,
text: "取消",
},
},
onClosed() {
resolveResult({ action: "cancel" });
},
},
async onSubmit() {
if (!info.authorRegistered) {
throw new Error(t("certd.onlinePluginAuthorNotRegistered"));
}
state.error = getVersionError(state.version, versionBaseline);
if (state.error) {
throw new Error(state.error);
}
resolveResult({ action: "publish", version: normalizeVersion(state.version) });
},
}).then(wrapper => {
formWrapper = wrapper;
});
});
}
async function publishLocalPlugin(row: any, options?: { beforePublish?: () => Promise<any>; afterPublish?: () => Promise<void> }) {
if (!row?.id || (row.type !== "store" && row.type !== "custom")) {
return;
}
const info = await api.OnlinePluginPublishInfo({
id: row.id,
});
const action = await openPublishManager(row, info);
if (action.action === "cancel") {
return;
}
if (action.action === "registered") {
await publishLocalPlugin(row, options);
return;
}
if (action.action !== "publish") {
return;
}
publishingPluginId.value = row.id;
try {
const publishRow = (await options?.beforePublish?.()) || row;
await api.OnlinePluginPublish({
id: publishRow.id,
version: action.version,
});
if (normalizeVersion(publishRow.version) !== action.version) {
await api.UpdateObj({
...publishRow,
version: action.version,
});
}
await pluginStore.reload();
await options?.afterPublish?.();
message.success(t("certd.onlinePluginPublishSuccess"));
} finally {
publishingPluginId.value = "";
}
}
return {
isPublishingPlugin,
publishLocalPlugin,
registerPluginAuthor,
};
}