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
+14
View File
@@ -87,6 +87,20 @@
"plus_use_prod": "false",
"PLUS_SERVER_BASE_URL": "http://127.0.0.1:11007"
}
},
{
"name": "server-local-comm",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/packages/ui/certd-server",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev-localcomm"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env": {
"plus_use_prod": "false",
"PLUS_SERVER_BASE_URL": "http://127.0.0.1:11007"
}
}
],
"compounds": [
+3
View File
@@ -139,6 +139,9 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
- 列表管理、后台管理、记录查询、CRUD 表格页面优先使用 Fast Crud;开发或重构前读 `.trae/skills/fast-crud-page-dev/SKILL.md`
- 只有轻量只读展示、强交互自定义界面或既有页面模式明显不适合 Fast Crud 时,才手写 `a-table` / 自定义列表,并在回复中说明。
- 内嵌 Fast Crud 时,外层必须有稳定高度或完整 `flex: 1; min-height: 0` 链路。
- 前端组件样式统一写在 `<style>` / Less / CSS 文件里,通过样式名映射到元素;尽量不要在元素上直接写 `style`
- 每个组件都要有一个稳定的根样式名,并把组件下方样式全部包在该根样式名内;尽量不要使用 `scoped`
- 可复用的公共样式名放在 `packages/ui/certd-client/src/style` 下维护,优先使用 `cd-` 前缀,避免散落在业务组件里重复定义。
- 后台管理列表展示或筛选用户字段时,优先参考 `packages/ui/certd-client/src/views/sys/suite/user-suite/crud.tsx``userId` 字段模式,用 `table-select` + `/sys/authority/user/getSimpleUserByIds` 字典回显和搜索。
- 对话框里只做确认可用 `Modal.confirm`;有字段输入、表单校验或提交字段时,必须用 `useFormDialog` / `openFormDialog`
@@ -568,6 +568,7 @@ export class RuntimeDepsService {
const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env: tryEnv });
if (result.code === 0) {
this.writeInstallState(statePath, { installedAt: new Date().toISOString(), registryUrl: tryUrl, dependenciesHash, nodeVersion: process.version, pnpmVersion, lockFileExists: fs.existsSync(lockPath) });
log.info(`${result.stdout?.slice(-2000) || "无npm安装日志输出"}`);
log.info("第三方依赖安装完成");
return { registryUrl: tryUrl, packageJsonPath };
}
@@ -575,9 +576,9 @@ export class RuntimeDepsService {
const outOutput = (result.stdout || "").trim();
lastError = errOutput || outOutput || "unknown error";
log.info(`镜像 ${tryUrl || "默认"} 安装失败,退出码: ${result.code}${urlsToTry.length > 1 ? ",尝试下一个镜像..." : ""}`);
log.info(` pnpm stderr: ${(errOutput || "(空)").slice(0, 2000)}`);
log.info(` pnpm stderr: ${(errOutput || "无npm安装日志输出").slice(-2000)}`);
if (outOutput) {
log.info(` pnpm stdout: ${outOutput.slice(0, 2000)}`);
log.info(` pnpm stdout: ${outOutput.slice(-2000)}`);
}
}
this.writeInstallState(statePath, {
@@ -1,4 +1,4 @@
import { nanoid } from 'nanoid';
import { nanoid } from "nanoid";
export type IframeMessageData<T> = {
action: string;
@@ -29,10 +29,12 @@ export class IframeClient {
onError?: any;
handlers: Record<string, (data: IframeMessageData<any>) => Promise<void>> = {};
private messageHandler: (event: MessageEvent<IframeMessageData<any>>) => Promise<void>;
constructor(iframe?: HTMLIFrameElement, onError?: (e: any) => void) {
this.iframe = iframe;
this.onError = onError;
window.addEventListener('message', async (event: MessageEvent<IframeMessageData<any>>) => {
this.messageHandler = async (event: MessageEvent<IframeMessageData<any>>) => {
const data = event.data;
if (data.action) {
console.log(`收到消息[isSub:${this.isInFrame()}]`, data);
@@ -40,20 +42,21 @@ export class IframeClient {
const handler = this.handlers[data.action];
if (handler) {
const res = await handler(data);
if (data.id && data.action !== 'reply') {
await this.send('reply', res, data.id);
if (data.id && data.action !== "reply") {
await this.send("reply", res, data.id);
}
} else {
throw new Error(`action:${data.action} 未注册处理器,可能版本过低`);
}
} catch (e: any) {
console.error(e);
await this.send('reply', {}, data.id, 500, e.message);
await this.send("reply", {}, data.id, 500, e.message);
}
}
});
};
window.addEventListener("message", this.messageHandler);
this.register('reply', async data => {
this.register("reply", async data => {
const req = this.requestQueue[data.replyId!];
if (req) {
req.onReply(data);
@@ -61,11 +64,17 @@ export class IframeClient {
}
});
}
isInFrame() {
public destroy() {
window.removeEventListener("message", this.messageHandler);
this.requestQueue = {};
this.handlers = {};
}
public isInFrame() {
return window.self !== window.top;
}
register<T = any>(action: string, handler: (data: IframeMessageData<T>) => Promise<any>) {
public register<T = any>(action: string, handler: (data: IframeMessageData<T>) => Promise<any>) {
this.handlers[action] = handler;
}
@@ -106,12 +115,12 @@ export class IframeClient {
console.log(`send message[isSub:${this.isInFrame()}]:`, reqMessageData);
if (!this.iframe) {
if (!window.parent) {
reject('当前页面不在 iframe 中');
reject("当前页面不在 iframe 中");
}
window.parent.postMessage(reqMessageData, '*');
window.parent.postMessage(reqMessageData, "*");
} else {
//子页面
this.iframe.contentWindow?.postMessage(reqMessageData, '*');
this.iframe.contentWindow?.postMessage(reqMessageData, "*");
}
} catch (e) {
console.error(e);
@@ -279,3 +279,11 @@ export class SysSafeSetting extends BaseSettings {
autoHiddenTimes: 5,
};
}
export class SysPluginSetting extends BaseSettings {
static __title__ = "系统插件设置";
static __key__ = "sys.plugin";
static __access__ = "private";
lastSyncTime?: number;
}
@@ -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,
};
}
@@ -26,8 +26,9 @@ typeorm:
#
#plus:
# server:
# baseUrls: ['http://127.0.0.1:11007']
# baseUrls: ['http://localhost:11007']
#
#account:
# server:
# baseUrl: 'http://127.0.0.1:1017/subject'
# baseUrl: 'http://localhost:1017/subject'
@@ -30,4 +30,4 @@ typeorm:
account:
server:
baseUrl: 'http://127.0.0.1:1017/subject'
baseUrl: 'http://localhost:1017/subject'
+1 -1
View File
@@ -17,4 +17,4 @@ typeorm:
#
#account:
# server:
# baseUrl: 'http://127.0.0.1:1017/subject'
# baseUrl: 'http://localhost:1017/subject'
@@ -0,0 +1,16 @@
ALTER TABLE "pi_plugin" ADD COLUMN "app_id" integer;
ALTER TABLE "pi_plugin" ADD COLUMN "developer_id" integer;
ALTER TABLE "pi_plugin" ADD COLUMN "full_name" varchar(200);
ALTER TABLE "pi_plugin" ADD COLUMN "latest" varchar(100);
ALTER TABLE "pi_plugin" ADD COLUMN "status" varchar(100);
ALTER TABLE "pi_plugin" ADD COLUMN "download_count" integer;
ALTER TABLE "pi_plugin" ADD COLUMN "score" real;
ALTER TABLE "pi_plugin" ADD COLUMN "sync_time" integer;
CREATE UNIQUE INDEX "index_plugin_full_name" ON "pi_plugin" ("full_name");
CREATE INDEX "index_plugin_plugin_type" ON "pi_plugin" ("pluginType");
CREATE INDEX "index_plugin_group" ON "pi_plugin" ("group");
CREATE INDEX "index_plugin_developer_id" ON "pi_plugin" ("developer_id");
CREATE INDEX "index_plugin_sync_time" ON "pi_plugin" ("sync_time");
UPDATE "pi_plugin" SET "type" = 'store' WHERE "type" = 'custom';
@@ -1,24 +0,0 @@
CREATE TABLE "pi_plugin_market_item"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"app_id" integer,
"full_name" varchar(200) NOT NULL,
"author" varchar(100),
"name" varchar(100),
"plugin_type" varchar(100),
"title" varchar(200),
"icon" varchar(200),
"group" varchar(100),
"desc" varchar(1000),
"latest" varchar(100),
"status" varchar(100),
"download_count" integer,
"sync_time" integer,
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE UNIQUE INDEX "index_plugin_market_item_full_name" ON "pi_plugin_market_item" ("full_name");
CREATE INDEX "index_plugin_market_item_plugin_type" ON "pi_plugin_market_item" ("plugin_type");
CREATE INDEX "index_plugin_market_item_group" ON "pi_plugin_market_item" ("group");
CREATE INDEX "index_plugin_market_item_sync_time" ON "pi_plugin_market_item" ("sync_time");
@@ -0,0 +1,5 @@
ALTER TABLE "pi_plugin" ADD COLUMN "installed" boolean NOT NULL DEFAULT true;
UPDATE "pi_plugin" SET "installed" = true;
ALTER TABLE "pi_plugin" ADD COLUMN "ai_check_status" varchar(32) NOT NULL DEFAULT '';
+1 -1
View File
@@ -9,7 +9,7 @@
"dev-start": "cross-env NODE_ENV=dev node --optimize-for-size ./bootstrap.js",
"dc": "cd ../../../ && pnpm run dev",
"dev": "cross-env NODE_ENV=dev mwtsc --watch --run @midwayjs/mock/app",
"dev-commlocal": "cross-env NODE_ENV=dev-commlocal mwtsc --watch --run @midwayjs/mock/app",
"dev-localcomm": "cross-env NODE_ENV=dev-localcomm mwtsc --watch --run @midwayjs/mock/app",
"dev-commpro": "cross-env NODE_ENV=dev-commpro mwtsc --watch --run @midwayjs/mock/app",
"dev-pg": "cross-env NODE_ENV=dev-pg mwtsc --watch --run @midwayjs/mock/app",
"dev-mysql": "cross-env NODE_ENV=dev-mysql mwtsc --watch --run @midwayjs/mock/app",
@@ -42,9 +42,10 @@ process.on("uncaughtException", error => {
// }
// setInterval(log, 200);
// log()
// }
// startHeapLog();
// }127.0.0.1
// startHeapLog();
@Configuration({
detectorOptions: {
ignore: ["**/plugins/**"],
@@ -1,7 +1,19 @@
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
import { merge } from "lodash-es";
import { CrudController } from "@certd/lib-server";
import { OnlinePluginInstallReq, OnlinePluginListReq, PluginImportReq, PluginService } from "../../../modules/plugin/service/plugin-service.js";
import {
OnlinePluginAuthorAddReq,
OnlinePluginDetailReq,
OnlinePluginInstallReq,
OnlinePluginListReq,
OnlinePluginPublishInfoReq,
OnlinePluginPublishReq,
OnlinePluginRateReq,
OnlinePluginSourceReq,
OnlinePluginVersionSubmitReq,
PluginImportReq,
PluginService,
} from "../../../modules/plugin/service/plugin-service.js";
import { CommPluginConfig, PluginConfig, PluginConfigService } from "../../../modules/plugin/service/plugin-config-service.js";
import { AuditType } from "../../../modules/sys/enterprise/service/audit-constants.js";
/**
@@ -40,8 +52,13 @@ export class PluginController extends CrudController<PluginService> {
const def: any = {
isDefault: false,
disabled: false,
type: "store",
};
merge(bean, def);
bean.fullName = bean.name;
if (bean.author){
bean.fullName = bean.author + "/" + bean.name;
}
const res = await super.add(bean);
await this.auditLog({ content: `新增了插件「${bean.name}」(ID:${res.data}, 类型:${bean.type})` });
return res;
@@ -49,6 +66,7 @@ export class PluginController extends CrudController<PluginService> {
@Post("/update", { description: "sys:settings:edit", summary: "更新插件" })
async update(@Body(ALL) bean: any) {
await this.service.ensurePluginEditable(bean.id);
const res = await super.update(bean);
await this.auditLog({ content: `修改了插件「${bean.name}」(ID:${bean.id})` });
return res;
@@ -121,6 +139,12 @@ export class PluginController extends CrudController<PluginService> {
return this.ok(res);
}
@Post("/online/setting", { description: "sys:settings:view", summary: "查询在线插件同步设置" })
async onlineSetting() {
const res = await this.service.getOnlinePluginSetting();
return this.ok(res);
}
@Post("/online/sync", { description: "sys:settings:edit", summary: "同步在线插件" })
async onlineSync() {
const res = await this.service.syncOnlinePluginList();
@@ -135,6 +159,58 @@ export class PluginController extends CrudController<PluginService> {
return this.ok(res);
}
@Post("/online/detail", { description: "sys:settings:view", summary: "查看在线插件详情" })
async onlineDetail(@Body(ALL) body: OnlinePluginDetailReq) {
const res = await this.service.onlinePluginDetail(body);
return this.ok(res);
}
@Post("/online/source", { description: "sys:settings:view", summary: "查看在线插件源代码" })
async onlineSource(@Body(ALL) body: OnlinePluginSourceReq) {
const res = await this.service.onlinePluginSource(body);
return this.ok(res);
}
@Post("/online/rate", { description: "sys:settings:edit", summary: "在线插件评分" })
async onlineRate(@Body(ALL) body: OnlinePluginRateReq) {
const res = await this.service.rateOnlinePlugin(body);
await this.auditLog({ content: `给在线插件「${body.fullName || body.pluginId}」评分` });
return this.ok(res);
}
@Post("/online/version/submit", { description: "sys:settings:edit", summary: "提交在线插件版本" })
async onlineVersionSubmit(@Body(ALL) body: OnlinePluginVersionSubmitReq) {
const res = await this.service.submitOnlinePluginVersion(body);
await this.auditLog({ content: `提交了在线插件「${body.fullName}」的新版本` });
return this.ok(res);
}
@Post("/online/publish", { description: "sys:settings:edit", summary: "发布本地插件到市场" })
async onlinePublish(@Body(ALL) body: OnlinePluginPublishReq) {
const res = await this.service.publishLocalPlugin(body);
await this.auditLog({ content: `发布了本地插件(ID:${body.id})到插件市场` });
return this.ok(res);
}
@Post("/online/author/get", { description: "sys:settings:view", summary: "查询在线插件作者" })
async onlineAuthorGet() {
const res = await this.service.getOnlinePluginAuthor();
return this.ok(res);
}
@Post("/online/author/add", { description: "sys:settings:edit", summary: "注册在线插件作者" })
async onlineAuthorAdd(@Body(ALL) body: OnlinePluginAuthorAddReq) {
const res = await this.service.addOnlinePluginAuthor(body);
await this.auditLog({ content: `注册了在线插件作者「${body.name}` });
return this.ok(res);
}
@Post("/online/publish/info", { description: "sys:settings:view", summary: "查询本地插件发布信息" })
async onlinePublishInfo(@Body(ALL) body: OnlinePluginPublishInfoReq) {
const res = await this.service.getOnlinePluginPublishInfo(body);
return this.ok(res);
}
@Post("/export", { description: "sys:settings:edit", summary: "导出插件" })
async export(@Body("id") id: number) {
const res = await this.service.exportPlugin(id);
@@ -1,60 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("pi_plugin_market_item")
export class PluginMarketItemEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: "app_id", comment: "平台应用ID", nullable: true })
appId: number;
@Column({ name: "full_name", comment: "插件完整名称", length: 200 })
fullName: string;
@Column({ name: "author", comment: "作者", length: 100, nullable: true })
author: string;
@Column({ name: "name", comment: "插件名称", length: 100, nullable: true })
name: string;
@Column({ name: "plugin_type", comment: "插件类型", length: 100, nullable: true })
pluginType: string;
@Column({ name: "title", comment: "标题", length: 200, nullable: true })
title: string;
@Column({ name: "icon", comment: "图标", length: 200, nullable: true })
icon: string;
@Column({ name: "group", comment: "分组", length: 100, nullable: true })
group: string;
@Column({ name: "desc", comment: "描述", length: 1000, nullable: true })
desc: string;
@Column({ name: "latest", comment: "最新版本", length: 100, nullable: true })
latest: string;
@Column({ name: "status", comment: "状态", length: 100, nullable: true })
status: string;
@Column({ name: "download_count", comment: "下载次数", nullable: true })
downloadCount: number;
@Column({ name: "sync_time", comment: "同步时间", nullable: true })
syncTime: number;
@Column({
name: "create_time",
comment: "创建时间",
default: () => "CURRENT_TIMESTAMP",
})
createTime: Date;
@Column({
name: "update_time",
comment: "修改时间",
default: () => "CURRENT_TIMESTAMP",
})
updateTime: Date;
}
@@ -30,7 +30,7 @@ export class PluginEntity {
content: string;
@Column({ comment: "类型", length: 100, nullable: true })
type: string; // builtIn | local | download
type: string; // builtIn | store
@Column({ comment: "启用/禁用", default: false })
disabled: boolean;
@@ -41,6 +41,9 @@ export class PluginEntity {
@Column({ comment: "插件类型", length: 100, nullable: true })
pluginType: string;
@Column({ comment: "是否已安装", default: true })
installed: boolean;
@Column({ comment: "元数据", length: 40960, nullable: true })
metadata: string;
@@ -50,6 +53,33 @@ export class PluginEntity {
@Column({ comment: "作者", length: 100, nullable: true })
author: string;
@Column({ name: "app_id", comment: "平台应用ID", nullable: true })
appId: number;
@Column({ name: "developer_id", comment: "平台开发者ID", nullable: true })
developerId: number;
@Column({ name: "full_name", comment: "插件完整名称", length: 200, nullable: true })
fullName: string;
@Column({ comment: "最新版本", length: 100, nullable: true })
latest: string;
@Column({ comment: "市场状态", length: 100, nullable: true })
status: string;
@Column({ name: "download_count", comment: "下载次数", nullable: true })
downloadCount: number;
@Column({ comment: "评分", nullable: true })
score: number;
@Column({ name: "ai_check_status", comment: "最新版本AI审核状态", length: 32, default: "" })
aiCheckStatus: string;
@Column({ name: "sync_time", comment: "同步时间", nullable: true })
syncTime: number;
@Column({
name: "create_time",
comment: "创建时间",
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,6 @@
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { addonRegistry, BaseService, PageReq, PlusService } from "@certd/lib-server";
import { addonRegistry, BaseService, PageReq, PlusService, SysInstallInfo, SysPluginSetting, SysSettingsService } from "@certd/lib-server";
import { PluginEntity } from "../entity/plugin.js";
import { PluginMarketItemEntity } from "../entity/plugin-market-item.js";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { Brackets, IsNull, Not, Repository } from "typeorm";
import { isComm } from "@certd/plus-core";
@@ -32,10 +31,71 @@ export type OnlinePluginInstallReq = {
version?: string;
};
export type OnlinePluginDetailReq = {
pluginId?: number;
fullName?: string;
commentPageNo?: number;
commentPageSize?: number;
};
export type OnlinePluginSourceReq = OnlinePluginDetailReq & {
version?: string;
};
export type OnlinePluginRateReq = OnlinePluginDetailReq & {
score: number;
comment: string;
};
export type OnlinePluginVersionSubmitReq = {
fullName: string;
version: string;
content: string;
minAppVersion?: string;
maxAppVersion?: string;
};
export type OnlinePluginPublishReq = {
id: number;
version?: string;
minAppVersion?: string;
maxAppVersion?: string;
};
export type OnlinePluginPublishInfoReq = {
id: number;
};
export type OnlinePluginAuthorBean = {
id?: number;
appId?: number;
appOwnerId?: number;
developerId?: number;
name?: string;
displayName?: string;
avatar?: string;
desc?: string;
status?: string;
};
export type OnlinePluginAuthorGetReply = {
registered?: boolean;
author?: OnlinePluginAuthorBean;
};
export type OnlinePluginAuthorAddReq = {
name: string;
displayName?: string;
avatar?: string;
desc?: string;
};
export type OnlinePluginBean = {
id?: number;
appId?: number;
developerId?: number;
author?: string;
type?: string;
pluginType?: string;
name?: string;
fullName?: string;
@@ -46,14 +106,41 @@ 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 OnlinePluginPublishInfo = {
localPlugin: Partial<PluginEntity>;
authorRegistered: boolean;
author?: OnlinePluginAuthorBean;
marketPlugin?: OnlinePluginBean;
versions: OnlinePluginVersionBean[];
};
const ONLINE_PLUGIN_SYNC_PAGE_SIZE = 200;
export function parseOnlinePluginFullName(fullName: string) {
@@ -134,6 +221,39 @@ function isBareModuleSpecifier(modulePath: string) {
return !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(modulePath);
}
function normalizePluginSourceType(type?: string) {
if (!type || type === "custom") {
return "store";
}
return type;
}
export function canEditStorePlugin(developerId?: number, bindUserId?: number) {
if (!developerId) {
return true;
}
return !!bindUserId && developerId === bindUserId;
}
export function getPluginFullName(plugin: { fullName?: string; author?: string; name?: string }) {
const fullName = `${plugin.fullName || ""}`.trim();
if (fullName) {
return fullName;
}
const name = `${plugin.name || ""}`.trim();
const author = `${plugin.author || ""}`.trim();
if (!author || !name || name.startsWith(`${author}/`)) {
return name;
}
return `${author}/${name}`;
}
function getPluginRegistryName(plugin: { fullName?: string; author?: string; name?: string; addonType?: string }) {
const fullName = getPluginFullName(plugin);
return plugin.addonType ? `${plugin.addonType}:${fullName}` : fullName;
}
async function importLocalModule(modulePath: string) {
if (!modulePath) {
throw new Error("modules path 不能为空");
@@ -153,30 +273,61 @@ export class PluginService extends BaseService<PluginEntity> {
@InjectEntityModel(PluginEntity)
repository: Repository<PluginEntity>;
@InjectEntityModel(PluginMarketItemEntity)
pluginMarketItemRepository: Repository<PluginMarketItemEntity>;
@Inject()
builtInPluginService: BuiltInPluginService;
@Inject("plusService")
plusService: PlusService;
@Inject()
sysSettingsService: SysSettingsService;
//@ts-ignore
getRepository() {
return this.repository;
}
private addInstalledPluginOnlyQuery(bq: any) {
bq.andWhere("(main.type != :storeType OR main.installed = :installed)", {
storeType: "store",
installed: true,
});
}
private filterBuiltInList(list: PluginEntity[], query: Partial<PluginEntity>) {
let records = list;
if (query.group) {
records = records.filter(item => item.group === query.group);
}
if (query.pluginType) {
records = records.filter(item => item.pluginType === query.pluginType);
}
if (query.name) {
const keyword = `${query.name}`.trim().toLowerCase();
records = records.filter(item => (item.name || "").toLowerCase().includes(keyword));
}
return records;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async page(pageReq: PageReq<PluginEntity>) {
pageReq.query = pageReq.query || {};
if (pageReq.query.type === "custom") {
pageReq.query.type = "store";
}
if (pageReq.query.type && pageReq.query.type !== "builtIn") {
return await super.page(pageReq);
const pageRes = await super.page(pageReq);
if (pageReq.query.type === "store") {
pageRes.records = (await this.attachOnlineInstallState(pageRes.records.map(item => this.toOnlinePluginBean(item)))) as any;
}
return pageRes;
}
//仅查询内置插件
const offset = pageReq.page.offset;
const limit = pageReq.page.limit;
const builtInList = await this.getBuiltInEntityList();
let builtInList = await this.getBuiltInEntityList();
builtInList = this.filterBuiltInList(builtInList, pageReq.query || {});
//获取分页数据
const data = builtInList.slice(offset, offset + limit);
@@ -332,6 +483,7 @@ export class PluginService extends BaseService<PluginEntity> {
* @param param
*/
async add(param: any) {
param.type = normalizePluginSourceType(param.type);
const old = await this.repository.findOne({
where: {
name: param.name,
@@ -393,11 +545,13 @@ export class PluginService extends BaseService<PluginEntity> {
return "";
}
private normalizeOnlinePluginMarketItem(item: OnlinePluginBean, syncTime: number, old?: PluginMarketItemEntity) {
return this.pluginMarketItemRepository.create({
private normalizeOnlinePluginRecord(item: OnlinePluginBean, syncTime: number, old?: PluginEntity) {
const fullName = this.getOnlinePluginFullName(item);
return this.repository.create({
id: old?.id,
appId: item.appId,
fullName: this.getOnlinePluginFullName(item),
developerId: item.developerId,
fullName,
author: item.author,
name: item.name,
pluginType: item.pluginType,
@@ -408,17 +562,31 @@ export class PluginService extends BaseService<PluginEntity> {
latest: item.latest,
status: item.status,
downloadCount: item.downloadCount,
score: item.score,
aiCheckStatus: item.aiCheckStatus,
syncTime,
type: old?.type || "store",
disabled: old?.disabled ?? false,
installed: old?.installed ?? false,
setting: old?.setting,
sysSetting: old?.sysSetting,
content: old?.content,
metadata: old?.metadata,
extra: old?.extra,
version: old?.version,
updateTime: new Date(),
});
}
private toOnlinePluginBean(item: PluginMarketItemEntity): OnlinePluginBean {
private toOnlinePluginBean(item: PluginEntity): OnlinePluginBean {
const isInstalled = item.installed === true;
return {
id: item.id,
appId: item.appId,
developerId: item.developerId,
fullName: item.fullName,
author: item.author,
type: item.type,
name: item.name,
pluginType: item.pluginType,
title: item.title,
@@ -428,14 +596,23 @@ export class PluginService extends BaseService<PluginEntity> {
latest: item.latest,
status: item.status,
downloadCount: item.downloadCount,
score: item.score,
aiCheckStatus: item.aiCheckStatus,
syncTime: item.syncTime,
installed: isInstalled,
localPluginId: isInstalled ? item.id : undefined,
localDisabled: isInstalled ? item.disabled : undefined,
installedVersion: isInstalled ? item.version : undefined,
};
}
private async findOnlinePluginMarketItems(req: OnlinePluginListReq) {
private async findOnlinePluginRecords(req: OnlinePluginListReq) {
const query = req || {};
const keyword = (query.keyword || "").trim().toLowerCase();
const builder = this.pluginMarketItemRepository.createQueryBuilder("item");
const builder = this.repository.createQueryBuilder("item");
builder.where("item.type = :type", {
type: "store",
});
if (query.pluginType) {
builder.andWhere("item.pluginType = :pluginType", {
pluginType: query.pluginType,
@@ -449,7 +626,7 @@ export class PluginService extends BaseService<PluginEntity> {
if (keyword) {
builder.andWhere(
new Brackets(qb => {
qb.where("LOWER(item.fullName) LIKE :keyword", { keyword: `%${keyword}%` })
qb.where("LOWER(COALESCE(item.fullName, '')) LIKE :keyword", { keyword: `%${keyword}%` })
.orWhere("LOWER(item.author) LIKE :keyword", { keyword: `%${keyword}%` })
.orWhere("LOWER(item.name) LIKE :keyword", { keyword: `%${keyword}%` })
.orWhere("LOWER(item.title) LIKE :keyword", { keyword: `%${keyword}%` })
@@ -464,38 +641,46 @@ export class PluginService extends BaseService<PluginEntity> {
private async findInstalledOnlinePlugin(parsed: { author: string; name: string }, record: OnlinePluginBean) {
const fullName = record.fullName || `${parsed.author}/${parsed.name}`;
const where: any[] = [
{
author: parsed.author,
name: parsed.name,
},
{
name: fullName,
},
];
if (record.pluginType) {
where.push({
pluginType: record.pluginType,
name: parsed.name,
});
}
return await this.repository.findOne({
where,
});
const builder = this.repository
.createQueryBuilder("plugin")
.where("plugin.type = :type", { type: "store" })
.andWhere("plugin.installed = :installed", { installed: true })
.andWhere(
new Brackets(qb => {
qb.where("plugin.fullName = :fullName", { fullName }).orWhere("plugin.name = :fullName", { fullName }).orWhere("plugin.author = :author AND plugin.name = :name", {
author: parsed.author,
name: parsed.name,
});
if (record.pluginType) {
qb.orWhere("plugin.pluginType = :pluginType AND plugin.name = :name", {
pluginType: record.pluginType,
name: parsed.name,
});
}
})
);
return await builder.getOne();
}
private async attachOnlineInstallState(list: OnlinePluginBean[]) {
const records: OnlinePluginBean[] = [];
let bindUserId = 0;
if (this.sysSettingsService) {
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
bindUserId = installInfo.bindUserId || 0;
}
for (const item of list) {
const record: OnlinePluginBean = { ...item };
const fullName = this.getOnlinePluginFullName(record);
record.fullName = fullName;
record.selfAuthored = !!bindUserId && record.developerId === bindUserId;
record.localEditable = canEditStorePlugin(record.developerId, bindUserId);
let parsed: { author: string; name: string };
try {
parsed = parseOnlinePluginFullName(fullName);
} catch (e) {
record.installed = false;
record.installed = !!record.localPluginId;
record.upgradeAvailable = false;
records.push(record);
continue;
@@ -507,13 +692,14 @@ export class PluginService extends BaseService<PluginEntity> {
record.localDisabled = localPlugin?.disabled;
record.installedVersion = localPlugin?.version;
record.upgradeAvailable = localPlugin ? isOnlinePluginUpgradeAvailable(localPlugin.version, record.latest) : false;
record.localEditable = canEditStorePlugin(localPlugin?.developerId ?? record.developerId, bindUserId);
records.push(record);
}
return records;
}
async onlinePluginList(req: OnlinePluginListReq) {
const list = await this.findOnlinePluginMarketItems(req);
const list = await this.findOnlinePluginRecords(req);
return await this.attachOnlineInstallState(list.map(item => this.toOnlinePluginBean(item)));
}
@@ -522,12 +708,14 @@ export class PluginService extends BaseService<PluginEntity> {
// 只同步市场列表元数据,插件 YAML 内容仍然在安装时按版本下载。
const pluginMap = new Map<string, OnlinePluginBean>();
const createdAtLt = Date.now();
let pageStart = 0;
while (true) {
const res = await this.plusService.request({
url: "/activation/plugin/list",
url: "/activation/plugin/page",
method: "post",
data: {
createdAtLt,
page: {
start: pageStart,
limit: ONLINE_PLUGIN_SYNC_PAGE_SIZE,
@@ -550,21 +738,44 @@ export class PluginService extends BaseService<PluginEntity> {
record.fullName = fullName;
pluginMap.set(fullName, record);
}
const total = Number(res?.page?.total || 0);
if (total > 0) {
pageStart += ONLINE_PLUGIN_SYNC_PAGE_SIZE;
if (pageStart >= total) {
break;
}
continue;
}
if (list.length < ONLINE_PLUGIN_SYNC_PAGE_SIZE) {
break;
}
pageStart += ONLINE_PLUGIN_SYNC_PAGE_SIZE;
}
const existingList = await this.pluginMarketItemRepository.find();
const existingMap = new Map(existingList.map(item => [item.fullName, item]));
const existingList = await this.repository.find({
where: {
type: "store",
},
});
const existingMap = new Map<string, PluginEntity>();
for (const item of existingList) {
const fullName = item.fullName || this.getOnlinePluginFullName(item as any);
if (fullName) {
existingMap.set(fullName, item);
}
}
const syncTime = Date.now();
const records = Array.from(pluginMap.values()).map(item => {
return this.normalizeOnlinePluginMarketItem(item, syncTime, existingMap.get(item.fullName));
return this.normalizeOnlinePluginRecord(item, syncTime, existingMap.get(item.fullName));
});
await this.pluginMarketItemRepository.save(records);
await this.repository.save(records);
await this.saveOnlinePluginSyncTime(syncTime);
return await this.onlinePluginList({});
}
async getOnlinePluginSetting() {
return await this.sysSettingsService.getSetting<SysPluginSetting>(SysPluginSetting);
}
async installOnlinePlugin(req: OnlinePluginInstallReq) {
const fullName = (req.fullName || "").trim();
parseOnlinePluginFullName(fullName);
@@ -597,12 +808,243 @@ export class PluginService extends BaseService<PluginEntity> {
};
}
async onlinePluginDetail(req: OnlinePluginDetailReq) {
await this.plusService.register();
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
const res = await this.plusService.request({
url: "/activation/plugin/detail",
method: "post",
data: {
pluginId: req.pluginId || 0,
fullName: req.fullName || "",
userId: installInfo.bindUserId || 0,
commentPageNo: req.commentPageNo || 1,
commentPageSize: req.commentPageSize || 5,
},
});
if (res?.plugin) {
const [plugin] = await this.attachOnlineInstallState([res.plugin]);
res.plugin = plugin;
}
return res;
}
async onlinePluginSource(req: OnlinePluginSourceReq) {
await this.plusService.register();
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
return await this.plusService.request({
url: "/activation/plugin/source",
method: "post",
data: {
pluginId: req.pluginId || 0,
fullName: req.fullName || "",
version: req.version || "",
userId: installInfo.bindUserId || 0,
},
});
}
async rateOnlinePlugin(req: OnlinePluginRateReq) {
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
if (!installInfo.bindUserId) {
throw new Error("请先绑定账号后再评分");
}
await this.plusService.register();
const res = await this.plusService.request({
url: "/activation/plugin/rate",
method: "post",
data: {
pluginId: req.pluginId || 0,
fullName: req.fullName || "",
userId: installInfo.bindUserId,
score: req.score,
comment: req.comment,
},
});
if (res?.plugin?.score != null) {
await this.repository.update(
{
type: "store",
fullName: res.plugin.fullName || req.fullName,
},
{
score: res.plugin.score,
}
);
}
return res;
}
async submitOnlinePluginVersion(req: OnlinePluginVersionSubmitReq) {
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
if (!installInfo.bindUserId) {
throw new Error("请先绑定账号后再发布版本");
}
await this.plusService.register();
return await this.plusService.request({
url: "/activation/plugin/version/submit",
method: "post",
data: {
userId: installInfo.bindUserId,
fullName: req.fullName,
version: req.version,
content: req.content,
minAppVersion: req.minAppVersion || "",
maxAppVersion: req.maxAppVersion || "",
},
});
}
async publishLocalPlugin(req: OnlinePluginPublishReq) {
if (!req.id) {
throw new Error("插件ID不能为空");
}
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
if (!installInfo.bindUserId) {
throw new Error("请先绑定账号后再发布插件");
}
const plugin = await this.info(req.id);
if (!plugin) {
throw new Error("插件不存在");
}
if (normalizePluginSourceType(plugin.type) !== "store") {
throw new Error("只有商店插件可以发布到插件市场");
}
if (!canEditStorePlugin(plugin.developerId, installInfo.bindUserId)) {
throw new Error("当前绑定账号无权编辑该插件");
}
const content = await this.exportPlugin(req.id);
await this.plusService.register();
return await this.plusService.request({
url: "/activation/plugin/publish",
method: "post",
data: {
userId: installInfo.bindUserId,
content,
version: req.version || plugin.version || "",
minAppVersion: req.minAppVersion || "",
maxAppVersion: req.maxAppVersion || "",
},
});
}
async ensurePluginEditable(id: number) {
const plugin = await this.info(id);
if (!plugin) {
throw new Error("插件不存在");
}
if (plugin.type === "custom") {
return plugin;
}
if (plugin.type !== "store") {
throw new Error("该插件不允许编辑");
}
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
if (!canEditStorePlugin(plugin.developerId, installInfo.bindUserId)) {
throw new Error("当前绑定账号无权编辑该插件");
}
return plugin;
}
private async getBindUserId(action: string) {
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
if (!installInfo.bindUserId) {
throw new Error(`请先绑定账号后再${action}`);
}
return installInfo.bindUserId;
}
async getOnlinePluginAuthor(): Promise<OnlinePluginAuthorGetReply> {
const bindUserId = await this.getBindUserId("发布插件");
await this.plusService.register();
return await this.plusService.request({
url: "/activation/plugin/author/get",
method: "post",
data: {
userId: bindUserId,
},
});
}
async addOnlinePluginAuthor(req: OnlinePluginAuthorAddReq): Promise<OnlinePluginAuthorBean> {
const bindUserId = await this.getBindUserId("注册插件作者");
await this.plusService.register();
return await this.plusService.request({
url: "/activation/plugin/author/add",
method: "post",
data: {
userId: bindUserId,
name: req.name,
displayName: req.displayName || "",
avatar: req.avatar || "",
desc: req.desc || "",
},
});
}
async getOnlinePluginPublishInfo(req: OnlinePluginPublishInfoReq): Promise<OnlinePluginPublishInfo> {
if (!req.id) {
throw new Error("插件ID不能为空");
}
const plugin = await this.info(req.id);
if (!plugin) {
throw new Error("插件不存在");
}
if (normalizePluginSourceType(plugin.type) !== "store") {
throw new Error("只有商店插件可以发布到插件市场");
}
const bindUserId = await this.getBindUserId("发布插件");
if (!canEditStorePlugin(plugin.developerId, bindUserId)) {
throw new Error("当前绑定账号无权编辑该插件");
}
const authorReply = await this.getOnlinePluginAuthor();
const author = authorReply?.author;
const reply: OnlinePluginPublishInfo = {
localPlugin: {
id: plugin.id,
name: plugin.name,
title: plugin.title,
pluginType: plugin.pluginType,
group: plugin.group,
version: plugin.version,
desc: plugin.desc,
icon: plugin.icon,
},
authorRegistered: !!authorReply?.registered && !!author?.id,
author,
versions: [],
};
if (!reply.authorRegistered || !author?.name) {
return reply;
}
const fullName = `${author.name}/${plugin.name}`;
try {
const detail = await this.onlinePluginDetail({
fullName,
});
reply.marketPlugin = detail?.plugin;
reply.versions = detail?.versions || [];
} catch (e) {
logger.warn("get online plugin detail failed", e);
}
return reply;
}
private async saveOnlinePluginSyncTime(syncTime: number) {
const setting = await this.sysSettingsService.getSetting<SysPluginSetting>(SysPluginSetting);
setting.lastSyncTime = syncTime;
await this.sysSettingsService.saveSetting(setting);
}
private async refreshOnlinePluginDownloadCount(fullName: string, downloadCount?: number) {
if (!this.pluginMarketItemRepository || !fullName || downloadCount == null) {
if (!fullName || downloadCount == null) {
return;
}
await this.pluginMarketItemRepository.update(
await this.repository.update(
{
type: "store",
fullName,
},
{
@@ -619,10 +1061,14 @@ export class PluginService extends BaseService<PluginEntity> {
if (item.type === "builtIn") {
return;
}
let name = item.name;
if (item.author && !item.name.startsWith(`${item.author}/`)) {
name = `${item.author}/${item.name}`;
}
const metadata = item.metadata ? yaml.load(item.metadata) : {};
const extra = item.extra ? yaml.load(item.extra) : {};
const plugin = {
...item,
...metadata,
...extra,
};
const name = getPluginRegistryName(plugin);
if (item.pluginType === "access") {
accessRegistry.unRegister(name);
} else if (item.pluginType === "deploy") {
@@ -645,6 +1091,7 @@ export class PluginService extends BaseService<PluginEntity> {
}
async update(param: any) {
param.type = normalizePluginSourceType(param.type);
const old = await this.repository.findOne({
where: {
name: param.name,
@@ -730,6 +1177,7 @@ export class PluginService extends BaseService<PluginEntity> {
bq.andWhere("type != :type", {
type: "builtIn",
});
this.addInstalledPluginOnlyQuery(bq);
},
});
@@ -771,13 +1219,9 @@ export class PluginService extends BaseService<PluginEntity> {
delete item.metadata;
delete item.content;
delete item.extra;
if (item.author && !item.name.startsWith(`${item.author}/`)) {
item.name = item.author + "/" + item.name;
}
let name = item.name;
if (item.addonType) {
name = item.addonType + ":" + name;
}
const fullName = getPluginFullName(item);
item.name = fullName;
const name = getPluginRegistryName(item);
let registry = null;
if (item.pluginType === "access") {
registry = accessRegistry;
@@ -800,7 +1244,7 @@ export class PluginService extends BaseService<PluginEntity> {
if (item.type === "builtIn") {
return await this.getPluginClassFromFile(item);
} else {
return await this.getPluginClassFromDb(name);
return await this.getPluginClassFromDb(fullName);
}
},
});
@@ -813,6 +1257,7 @@ export class PluginService extends BaseService<PluginEntity> {
bq.andWhere("type != :type", {
type: "builtIn",
});
this.addInstalledPluginOnlyQuery(bq);
},
});
const list = [...builtInList];
@@ -855,12 +1300,30 @@ export class PluginService extends BaseService<PluginEntity> {
}
delete loaded.id;
const old = await this.repository.findOne({
where: {
name: loaded.name,
author: loaded.author,
},
});
const fullName = loaded.fullName || (loaded.author && loaded.name ? `${loaded.author}/${loaded.name}` : "");
const entityType = normalizePluginSourceType(req.type || loaded.type);
const old =
entityType === "store"
? await this.repository.findOne({
where: [
{
type: "store",
fullName,
},
{
type: "store",
author: loaded.author,
name: loaded.name,
},
],
})
: await this.repository.findOne({
where: {
name: loaded.name,
author: loaded.author,
},
});
const metadata = {
input: loaded.input,
@@ -875,11 +1338,19 @@ export class PluginService extends BaseService<PluginEntity> {
const pluginEntity = {
...loaded,
type: req.type || loaded.type || "custom",
appId: entityType === "store" ? (old?.appId ?? loaded.appId) : loaded.appId,
developerId: entityType === "store" ? (old?.developerId ?? loaded.developerId) : loaded.developerId,
fullName: entityType === "store" ? fullName || old?.fullName : old?.fullName,
type: entityType,
metadata: yaml.dump(metadata),
extra: yaml.dump(extra),
content: loaded.content,
disabled: false,
installed: true,
latest: old?.latest ?? loaded.latest,
status: old?.status ?? loaded.status,
downloadCount: old?.downloadCount ?? loaded.downloadCount,
syncTime: old?.syncTime ?? loaded.syncTime,
disabled: old?.disabled ?? false,
};
if (!pluginEntity.pluginType) {
throw new Error(`插件类型不能为空`);
@@ -904,7 +1375,21 @@ export class PluginService extends BaseService<PluginEntity> {
async deleteByIds(ids: any[]) {
ids = this.filterIds(ids);
for (const id of ids) {
const item = await this.info(id);
if (!item) {
continue;
}
await this.unRegisterById(id);
if (item.type === "store" && item.developerId) {
await this.repository.update(
{ id },
{
installed: false,
disabled: false,
}
);
continue;
}
await this.delete(id);
}
}