Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev

This commit is contained in:
xiaojunnuo
2026-08-22 17:16:41 +08:00
21 changed files with 1456 additions and 51 deletions
+3 -1
View File
@@ -153,7 +153,9 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
- 前端用户提示**统一使用 `notification`**(ant-design-vue 的 `notification.success({ message: X })` / `.error` / `.warning` / `.info`),**不要使用 `message` API**;`src/vben/` 目录(vben 框架内置布局/组件)保持原样不动。
- 不要运行前端 `pnpm tsc` / `vue-tsc`;当前 `vue-tsc@1.8.27` 会抛无效内部错误。前端 `test:unit` 只是占位脚本,也不要跑。
- 前端 TS/Vue/locale 改动后,只对本次改动文件运行现有 Prettier / ESLint:`packages\ui\certd-client\node_modules\.bin\prettier.cmd --write <files>` 和 `packages\ui\certd-client\node_modules\.bin\eslint.cmd --fix <files>`。
- Vue 模板中需要用 JSX 动态渲染时,使用 `<script setup lang="tsx">` 编写;不要使用 `h()` 创建 VNode。
- 需要手写 render 的地方(如 `Modal.confirm` 的 `content`、`useFormDialog` 的 `body`、动态插槽等),一律用 JSX 编写,禁止使用 `h()` 创建 VNode:
- `.vue` 组件:把 `<script lang="ts" setup>` 改为 `<script lang="tsx" setup>`,然后在 JSX 中直接写元素(如 `content: () => (<div class="xxx">...</div>)`)。
- `.ts` 文件:直接把文件后缀改为 `.tsx`,再写 JSX render。
- 列表管理、后台管理、记录查询、CRUD 表格页面优先使用 Fast Crud;开发或重构前读 `.trae/skills/fast-crud-page-dev/SKILL.md`。
- 只有轻量只读展示、强交互自定义界面或既有页面模式明显不适合 Fast Crud 时,才手写 `a-table` / 自定义列表,并在回复中说明。
- 内嵌 Fast Crud 时,外层必须有稳定高度或完整 `flex: 1; min-height: 0` 链路。
@@ -1,9 +1,9 @@
<template>
<div class="kv-input">
<div v-for="(item, index) of items" :key="index" class="kv-row">
<a-input :value="item.key" placeholder="名称" class="kv-key" @input="onKeyChange(index, $event)" @blur="emitValue" />
<a-input :value="item.key" placeholder="名称" class="kv-key" @input="onKeyChange(index, $event)" />
<span class="kv-sep">:</span>
<a-input :value="item.value" placeholder="版本" class="kv-value" @input="onValueChange(index, $event)" @blur="emitValue" />
<a-input :value="item.value" placeholder="版本" class="kv-value" @input="onValueChange(index, $event)" />
<a-button type="link" danger @click="removeItem(index)">
<template #icon><DeleteOutlined /></template>
</a-button>
@@ -16,7 +16,7 @@
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import { nextTick, ref, watch } from "vue";
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
const props = defineProps<{
@@ -34,6 +34,9 @@ interface KvItem {
const items = ref<KvItem[]>([]);
// emitting 必须在 watch(immediate)之前声明,避免 TDZ 报错
const emitting = ref(false);
function initItems(val: Record<string, string>) {
if (val && typeof val === "object" && !Array.isArray(val)) {
items.value = Object.entries(val).map(([k, v]) => ({
@@ -48,11 +51,23 @@ function initItems(val: Record<string, string>) {
watch(
() => props.modelValue,
val => {
if (emitting.value) {
// 自身 emit 引起的 modelValue 回写,跳过重建,避免输入框被重置
return;
}
initItems(val);
},
{ deep: true, immediate: true }
);
function emitValue() {
emitting.value = true;
emit("update:modelValue", rebuildRecord());
void nextTick(() => {
emitting.value = false;
});
}
function rebuildRecord(): Record<string, string> {
const record: Record<string, string> = {};
for (const item of items.value) {
@@ -64,18 +79,16 @@ function rebuildRecord(): Record<string, string> {
return record;
}
function emitValue() {
emit("update:modelValue", rebuildRecord());
}
function onKeyChange(index: number, event: Event) {
const target = event.target as HTMLInputElement;
items.value[index].key = target.value;
emitValue();
}
function onValueChange(index: number, event: Event) {
const target = event.target as HTMLInputElement;
items.value[index].value = target.value;
emitValue();
}
function addItem() {
@@ -26,8 +26,7 @@ export default {
store: "Store",
version: "Version",
pluginDependencies: "Plugin Dependencies",
pluginDependenciesHelper:
"Format: pluginType:pluginName, use * for version\nSupported: plugin:name, access:name, notification:name, dnsProvider:name, addon:subtype:name\nExample: access:AliyunAccess, plugin:DeployToAliyunCDN",
pluginDependenciesHelper: "Select dependency plugins from the synced market or built-in plugins (any version by default)",
thirdPartyDependencies: "Third-party Dependencies",
thirdPartyDependenciesHelper: "Format: npmPackageName: version. Auto-installed at runtime\nExample: aliyun-sdk: ^1.0.0",
editableRunStrategy: "Editable Run Strategy",
@@ -72,6 +71,13 @@ export default {
onlinePluginClickToUpdate: "Click to update",
onlinePluginSelfAuthored: "Mine",
onlinePluginInstallSuccess: "Online plugin installed successfully",
onlinePluginUnpublishedChanges: "Local changes not published yet",
onlinePluginVersionMustExceedLatest: "Please bump the version: the local version must be higher than the published version {version} to save",
onlinePluginDependenciesTitle: "Plugin dependencies detected",
onlinePluginDependenciesPrompt: "The following dependencies must be installed first:",
onlinePluginInstallDependencies: "Install dependencies",
onlinePluginDependencyNotFound: "Dependency plugin is not synced",
onlinePluginDependencyCycle: "Circular plugin dependency detected: {name}",
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",
@@ -26,7 +26,7 @@ export default {
store: "市场",
version: "版本",
pluginDependencies: "插件依赖",
pluginDependenciesHelper: "格式: 插件类型:插件名,版本号填 *\n支持: plugin:name、access:name、notification:name、dnsProvider:name、addon:subtype:name\n示例: access:AliyunAccess, plugin:DeployToAliyunCDN",
pluginDependenciesHelper: "从已同步的插件市场或内置插件中选择依赖插件(默认任意版本)",
thirdPartyDependencies: "第三方依赖",
thirdPartyDependenciesHelper: "格式: npm包名: 版本号,运行时自动安装\n示例: aliyun-sdk: ^1.0.0",
editableRunStrategy: "可编辑运行策略",
@@ -71,6 +71,13 @@ export default {
onlinePluginClickToUpdate: "点击更新",
onlinePluginSelfAuthored: "我的",
onlinePluginInstallSuccess: "在线插件安装成功",
onlinePluginUnpublishedChanges: "本地有改动还未发布",
onlinePluginVersionMustExceedLatest: "请先提升版本号:本地版本需要高于已发布版本 {version} 才能保存",
onlinePluginDependenciesTitle: "检测到插件依赖",
onlinePluginDependenciesPrompt: "安装此插件前,需要先安装以下依赖插件:",
onlinePluginInstallDependencies: "一键安装依赖",
onlinePluginDependencyNotFound: "依赖插件未同步",
onlinePluginDependencyCycle: "插件依赖存在循环:{name}",
onlinePluginUninstallSuccess: "在线插件卸载成功",
onlinePluginDeleteConfirm: "确定要卸载在线插件「{name}」吗?如果该插件已被流水线使用,删除可能会导致执行失败。",
onlinePluginPublish: "发布到插件市场",
@@ -197,6 +197,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
sorter: true,
},
},
remark: {
title: t("certd.columnRemark"),
type: "textarea",
column: {
width: 200,
ellipsis: true,
tooltip: true,
},
},
expirationDate: {
title: t("certd.domain.expirationDate"),
type: "date",
@@ -111,6 +111,7 @@ export type OnlinePluginBean = {
score?: number;
aiCheckStatus?: string;
vip?: string;
dependPlugins?: Record<string, string>;
editable?: boolean;
selfAuthored?: boolean;
installed?: boolean;
@@ -120,6 +121,7 @@ export type OnlinePluginBean = {
localDisabled?: boolean;
localEditable?: boolean;
syncTime?: number;
addonType?: string;
};
export type OnlinePluginVersionBean = {
@@ -0,0 +1,292 @@
<template>
<div class="depend-plugins-input">
<div v-for="(item, index) of displayItems" :key="index" class="depend-plugins-input__row">
<a-cascader :value="item.cascaderValue" :options="cascaderOptions" placeholder="选择依赖插件" class="depend-plugins-input__select" @change="onPluginChange(index, $event)" />
<a-button type="link" danger @click="removeItem(index)">
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</div>
<a-button type="dashed" block @click="addItem">
<template #icon>
<PlusOutlined />
</template>
添加依赖
</a-button>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
import * as api from "../api";
import { usePluginStore } from "/@/store/plugin";
import { useI18n } from "/src/locales";
const props = defineProps<{
modelValue: Record<string, string>;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: Record<string, string>): void;
}>();
interface DepItem {
/** 依赖 key 的「类型:引用」部分,如 access:tencent/TestAccess 或 access:aliyun */
optionValue: string;
}
interface PluginEntry {
value: string;
label: string;
/** 类型前缀(plugin 表示 deploy) */
pluginType: string;
group: string;
children?: any[];
}
interface CascaderOption {
value: string;
label: string;
children?: any[];
}
const { t } = useI18n();
const pluginStore = usePluginStore();
const items = ref<DepItem[]>([]);
const pluginEntries = ref<PluginEntry[]>([]);
const loadingOptions = ref(false);
const DEFAULT_GROUP = "默认";
const TYPE_LABELS: Record<string, string> = {
access: t("certd.auth"),
plugin: t("certd.deployPlugin"),
dnsProvider: t("certd.dns"),
notification: "通知",
addon: "Addon",
};
const TYPE_ORDER = ["access", "plugin", "dnsProvider", "notification", "addon"];
const cascaderOptions = computed<CascaderOption[]>(() => buildCascaderOptions(pluginEntries.value));
/**
* 渲染用:根据 optionValue 实时反查级联选中路径。
* 插件选项异步加载完成后 cascaderOptions 更新,这里自动跟随,实现编辑回显。
*/
const displayItems = computed(() => {
return items.value.map(item => ({
optionValue: item.optionValue,
cascaderValue: toCascaderValue(item.optionValue),
}));
});
// emitting 必须在 watch(immediate)之前声明,避免 TDZ 报错
const emitting = ref(false);
watch(
() => props.modelValue,
val => {
if (emitting.value) {
// 自身 emit 引起的 modelValue 回写,跳过重建,避免输入框被重置
return;
}
syncItemsFromModel(val);
},
{ deep: true, immediate: true }
);
onMounted(() => {
void loadPluginOptions();
});
function syncItemsFromModel(model: Record<string, string>) {
items.value = Object.keys(model || {}).map(key => ({
optionValue: key,
}));
}
async function loadPluginOptions() {
if (loadingOptions.value || pluginEntries.value.length > 0) {
return;
}
loadingOptions.value = true;
try {
await pluginStore.init();
const entries: PluginEntry[] = [];
const seen = new Set<string>();
// 已同步的市场插件(含已安装的)
const marketList = (await api.OnlinePluginList({})) || [];
for (const plugin of marketList) {
const ref = pluginFullName(plugin);
const typePrefix = pluginTypePrefix(plugin.pluginType, plugin.addonType);
if (!ref || !typePrefix) {
continue;
}
const value = `${typePrefix}:${ref}`;
if (seen.has(value)) {
continue;
}
seen.add(value);
entries.push({
value,
label: `${plugin.title || plugin.name} (${ref})`,
pluginType: typePrefix,
group: plugin.group || "",
});
}
// 内置插件
const groups = pluginStore.group?.groups || {};
for (const groupKey in groups) {
const group = groups[groupKey];
for (const plugin of group.plugins || []) {
if (plugin.type === "store") {
continue; // 已从市场列表加入
}
const ref = `${plugin.name || ""}`.trim();
const typePrefix = pluginTypePrefix(plugin.pluginType, plugin.addonType);
if (!ref || !typePrefix) {
continue;
}
const value = `${typePrefix}:${ref}`;
if (seen.has(value)) {
continue;
}
seen.add(value);
entries.push({
value,
label: `${plugin.title || plugin.name} (${ref})`,
pluginType: typePrefix,
group: plugin.group || "",
});
}
}
pluginEntries.value = entries;
} finally {
loadingOptions.value = false;
}
}
/**
* 构建级联选项:第一级插件类型,第二级分组。
* deploy 类型按真实分组展示;其他类型统一只有一个默认分组。
*/
function buildCascaderOptions(entries: PluginEntry[]): CascaderOption[] {
const byType = new Map<string, PluginEntry[]>();
for (const entry of entries) {
const list = byType.get(entry.pluginType) || [];
list.push(entry);
byType.set(entry.pluginType, list);
}
const options: CascaderOption[] = [];
for (const type of TYPE_ORDER) {
const list = byType.get(type);
if (!list || list.length === 0) {
continue;
}
const groups = new Map<string, PluginEntry[]>();
for (const entry of list) {
const groupKey = entry.pluginType === "plugin" && entry.group ? entry.group : DEFAULT_GROUP;
const groupList: PluginEntry[] = groups.get(groupKey) || [];
groupList.push(entry);
groups.set(groupKey, groupList);
}
const children: CascaderOption[] = Array.from(groups.entries()).map(([groupKey, groupList]) => ({
value: groupKey,
label: groupKey,
children: groupList.map(entry => ({
value: entry.value,
label: entry.label,
})),
}));
options.push({
value: type,
label: TYPE_LABELS[type] || type,
children,
});
}
return options;
}
function toCascaderValue(optionValue: string): string[] {
for (const type of cascaderOptions.value) {
for (const group of type.children) {
const leaf = group.children.find((item: any) => item.value === optionValue);
if (leaf) {
return [type.value, group.value, leaf.value];
}
}
}
return [];
}
function pluginFullName(plugin: any) {
return (plugin.fullName || (plugin.author && plugin.name ? `${plugin.author}/${plugin.name}` : "")).trim();
}
/**
* 依赖 key 的类型前缀:部署插件用 plugin,addon 插件带 addonType,其余用 pluginType。
*/
function pluginTypePrefix(pluginType?: string, addonType?: string) {
if (pluginType === "deploy") {
return "plugin";
}
if (pluginType === "addon") {
return addonType ? `addon:${addonType}` : "addon";
}
return `${pluginType || ""}`.trim();
}
function emitValue() {
emitting.value = true;
const record: Record<string, string> = {};
for (const item of items.value) {
const key = item.optionValue?.trim();
if (key) {
// 版本暂不做编辑,默认任意版本
record[key] = "*";
}
}
emit("update:modelValue", record);
void nextTick(() => {
emitting.value = false;
});
}
function onPluginChange(index: number, path: any[]) {
const leaf = Array.isArray(path) && path.length > 0 ? String(path[path.length - 1]) : "";
items.value[index].optionValue = leaf;
emitValue();
}
function addItem() {
items.value.push({ optionValue: "" });
}
function removeItem(index: number) {
items.value.splice(index, 1);
emitValue();
}
</script>
<style lang="less">
.depend-plugins-input {
.depend-plugins-input__row {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 4px;
.depend-plugins-input__select {
min-width: 0;
flex: 1;
}
}
}
</style>
@@ -7,13 +7,13 @@
</a-modal>
</template>
<script lang="ts" setup>
<script lang="tsx" setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { notification } from "ant-design-vue";
import { Modal, notification } from "ant-design-vue";
import { IframeClient } from "@certd/lib-iframe";
import * as api from "../api";
import { usePluginStore } from "/@/store/plugin";
import { fullNameValueFor, useOnlineInstall } from "../use-online-install";
import { useSettingStore } from "/src/store/settings";
import { useI18n } from "/src/locales";
const props = defineProps<{
open: boolean;
@@ -25,8 +25,9 @@ const emit = defineEmits<{
(e: "installed", value: { plugin: any; version?: string }): void;
}>();
const pluginStore = usePluginStore();
const settingStore = useSettingStore();
const { t } = useI18n();
const { resolveOnlinePluginDependencies, installOnlinePluginChain, openDependencyDetail } = useOnlineInstall();
const iframeRef = ref<HTMLIFrameElement>();
const loading = ref(false);
const visible = computed({
@@ -98,11 +99,69 @@ async function installPluginVersion(data: { fullName?: string; version?: string
if (!fullName || !data?.version) {
throw new Error("插件版本信息不完整");
}
await api.OnlinePluginInstall({
fullName,
version: data.version,
});
await pluginStore.reload();
const plugin = { ...props.plugin, fullName };
let dependencies: any[] = [];
try {
dependencies = await resolveOnlinePluginDependencies(plugin);
} catch (error: any) {
if (error instanceof Error && error.message.includes(t("certd.onlinePluginDependencyNotFound"))) {
// 依赖插件未同步到本地市场列表时,明确弹窗引导先同步
Modal.warning({
title: t("certd.onlinePluginDependencyNotFound"),
content: `${error.message},请先同步插件市场,确认依赖插件已发布后再安装`,
okText: t("certd.confirm"),
});
return;
}
notification.error({ message: error.message || String(error) });
return;
}
if (dependencies.length > 0) {
// 先弹窗确认安装依赖,再安装指定版本的目标插件
await new Promise<void>((resolve, reject) => {
Modal.confirm({
title: t("certd.onlinePluginDependenciesTitle"),
content: (
<div class="plugin-dependency-confirm">
<div>{t("certd.onlinePluginDependenciesPrompt")}</div>
<ul class="plugin-dependency-confirm__list">
{dependencies.map(item => (
<li key={fullNameValueFor(item)}>
<a
class="plugin-dependency-confirm__item"
onClick={(event: MouseEvent) => {
event.stopPropagation();
openDependencyDetail(item);
}}
>
{item.title || item.name || item.fullName} ({fullNameValueFor(item)})
</a>
</li>
))}
</ul>
</div>
),
okText: t("certd.onlinePluginInstallDependencies"),
cancelText: t("certd.cancel"),
async onOk() {
try {
await installOnlinePluginChain(dependencies, plugin, { version: data.version });
resolve();
} catch (error: any) {
// 依赖安装失败时给出明确错误提示,并保持弹窗打开便于重试
notification.error({ message: error.message || String(error) });
reject(error);
}
},
onCancel() {
reject(new Error("已取消安装"));
},
});
});
} else {
await installOnlinePluginChain([], plugin, { version: data.version });
}
emit("installed", {
plugin: props.plugin,
version: data.version,
@@ -39,10 +39,12 @@ import { useColumns } from "@fast-crud/fast-crud";
import yaml from "js-yaml";
import * as api from "../api";
import createCrudOptions from "../crud";
import { compareVersions } from "../use-online-install";
import { usePluginPublish } from "../use-publish";
import { usePluginStore } from "/@/store/plugin";
import { useSettingStore } from "/@/store/settings";
import { usePluginAiDev } from "../use-ai-dev";
import { useI18n } from "/src/locales";
defineOptions({
name: "PluginEditDialogBody",
@@ -58,6 +60,7 @@ const emit = defineEmits<{
const pluginStore = usePluginStore();
const settingStore = useSettingStore();
const { t } = useI18n();
const plugin = ref<any>({});
const formOptionsRef: Ref = ref();
const baseFormRef: Ref = ref({});
@@ -142,9 +145,16 @@ async function doSave() {
return;
}
validate();
const form = buildSubmitForm();
// 本地编辑后必须提升版本号才能保存:本地版本需要高于云端已发布版本
if (form.latest && form.version && compareVersions(form.version, form.latest) <= 0) {
notification.warning({
message: t("certd.onlinePluginVersionMustExceedLatest", { version: `v${form.latest}` }),
});
return;
}
saveLoading.value = true;
try {
const form = buildSubmitForm();
await api.UpdateObj(form);
plugin.value = form;
pluginStore.clear();
@@ -122,6 +122,7 @@
<a-tooltip v-if="!pluginCardState.isBuiltInPlugin" :title="versionTitle">
<span class="plugin-card__version" :class="{ 'is-upgradable': plugin.upgradeAvailable }" @click.stop="handleVersionClick">
v{{ currentVersion }}
<fs-icon v-if="hasLocalUnpublishedChanges" class="plugin-card__version-warning-icon" icon="ion:warning-outline" />
<fs-icon v-if="plugin.upgradeAvailable" class="plugin-card__version-icon" icon="carbon:upgrade" />
</span>
</a-tooltip>
@@ -135,12 +136,13 @@
</a-card>
</template>
<script lang="ts" setup>
<script lang="tsx" setup>
import { Modal, notification } from "ant-design-vue";
import { computed, h, ref } from "vue";
import { computed, ref } from "vue";
import * as api from "../api";
import { usePluginConfig } from "../use-config";
import { usePluginPublish } from "../use-publish";
import { compareVersions, fullNameValueFor, isOnlinePluginMissing, useOnlineInstall } from "../use-online-install";
import PluginEditDialogBody from "./plugin-edit-dialog-body.vue";
import { usePluginStore } from "/@/store/plugin";
import { useFormDialog } from "/@/use/use-dialog";
@@ -177,6 +179,7 @@ const pluginStore = usePluginStore();
const { openConfigDialog } = usePluginConfig();
const { isPublishingPlugin, publishLocalPlugin } = usePluginPublish();
const { openFormDialog } = useFormDialog();
const { resolveOnlinePluginDependencies, installOnlinePluginChain, openDependencyDetail } = useOnlineInstall();
const actionLoading = ref<PluginCardAction | "">("");
const editDialogBodyRef = ref();
@@ -256,6 +259,9 @@ const currentVersion = computed(() => {
});
const versionTitle = computed(() => {
if (hasLocalUnpublishedChanges.value) {
return `${t("certd.version")} v${currentVersion.value},${t("certd.onlinePluginUnpublishedChanges")}`;
}
if (props.source === "local") {
return `${t("certd.version")} v${currentVersion.value}`;
}
@@ -271,6 +277,15 @@ const versionTitle = computed(() => {
return `${t("certd.version")} -`;
});
/**
* 本地存在未发布的改动:当前版本(本地 version / 已安装 installedVersion)高于云端已发布版本 latest。
* latest 高于当前版本时走 upgradeAvailable 的升级提示。
*/
const hasLocalUnpublishedChanges = computed(() => {
const currentVersion = props.source === "local" ? props.plugin.version : props.plugin.installedVersion;
return !!currentVersion && !!props.plugin.latest && compareVersions(currentVersion, props.plugin.latest) > 0;
});
const toggleTitle = computed(() => {
const disabled = props.source === "local" ? props.plugin.disabled : props.plugin.localDisabled;
return disabled ? t("certd.onlinePluginClickToEnable") : t("certd.onlinePluginClickToDisable");
@@ -331,11 +346,7 @@ async function editPlugin() {
okText: "保存",
cancelText: "关闭",
},
body: () =>
h(PluginEditDialogBody, {
ref: editDialogBodyRef,
pluginId: pluginCardState.value.editPluginId,
}),
body: () => <PluginEditDialogBody ref={editDialogBodyRef} pluginId={pluginCardState.value.editPluginId} />,
async onSubmit() {
await editDialogBodyRef.value?.save?.();
emitChanged("edit");
@@ -381,30 +392,74 @@ async function installPlugin() {
if (!fullName) {
return;
}
let dependencies: any[] = [];
try {
await runAction("install", async () => {
await api.OnlinePluginInstall(
{
fullName,
version: props.plugin.latest,
},
{
showErrorNotify: false,
dependencies = await resolveOnlinePluginDependencies(props.plugin);
} catch (error: any) {
if (isDependencyNotSynced(error)) {
// 依赖插件未同步到本地市场列表时,明确弹窗引导先同步
Modal.warning({
title: t("certd.onlinePluginDependencyNotFound"),
content: `${error.message},请先同步插件市场,确认依赖插件已发布后再安装`,
okText: t("certd.confirm"),
});
return;
}
notification.error({ message: error.message || String(error) });
return;
}
if (dependencies.length > 0) {
Modal.confirm({
title: t("certd.onlinePluginDependenciesTitle"),
content: (
<div class="plugin-dependency-confirm">
<div>{t("certd.onlinePluginDependenciesPrompt")}</div>
<ul class="plugin-dependency-confirm__list">
{dependencies.map(item => (
<li key={fullNameValueFor(item)}>
<a
class="plugin-dependency-confirm__item"
onClick={(event: MouseEvent) => {
event.stopPropagation();
openDependencyDetail(item);
}}
>
{item.title || item.name || item.fullName} ({fullNameValueFor(item)})
</a>
</li>
))}
</ul>
</div>
),
okText: t("certd.onlinePluginInstallDependencies"),
cancelText: t("certd.cancel"),
async onOk() {
try {
await runAction("install", () => installOnlinePluginChain(dependencies, props.plugin));
} catch (error: any) {
// 依赖安装失败时给出明确错误提示,并保持弹窗打开便于重试
notification.error({ message: error.message || String(error) });
throw error;
}
);
notification.success({ message: t("certd.onlinePluginInstallSuccess") });
},
});
return;
}
try {
await runAction("install", () => installOnlinePluginChain([], props.plugin));
} catch (error: any) {
if (!isOnlinePluginMissing(error)) {
notification.error({ message: error.message });
notification.error({ message: error.message || String(error) });
return;
}
confirmRemoveMissingOnlinePlugin();
}
}
function isOnlinePluginMissing(error: unknown) {
return error instanceof Error && error.message.includes("插件不存在");
function isDependencyNotSynced(error: any) {
return error instanceof Error && error.message.includes(t("certd.onlinePluginDependencyNotFound"));
}
function confirmRemoveMissingOnlinePlugin() {
@@ -838,6 +893,14 @@ function handleVersionClick() {
line-height: 1;
}
.plugin-card__version-warning-icon {
margin-left: 2px;
color: #faad14;
font-size: 14px;
line-height: 1;
vertical-align: -1px;
}
.plugin-card__download-count {
display: inline-flex;
align-items: center;
@@ -967,4 +1030,54 @@ function handleVersionClick() {
height: 66vh;
}
}
.plugin-dependency-confirm {
.plugin-dependency-confirm__list {
max-height: 240px;
margin: 8px 0 0;
padding-left: 20px;
overflow: auto;
color: #5f6b7a;
font-size: 12px;
line-height: 22px;
.plugin-dependency-confirm__item {
color: #1677ff;
cursor: pointer;
&:hover {
color: #0958d9;
text-decoration: underline;
}
}
}
}
.plugin-dependency-detail-dialog {
.plugin-dependency-detail {
display: flex;
flex-direction: column;
gap: 10px;
.plugin-dependency-detail__item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
line-height: 20px;
}
.plugin-dependency-detail__label {
width: 76px;
flex: none;
color: #8c8c8c;
text-align: right;
}
.plugin-dependency-detail__desc {
white-space: pre-wrap;
word-break: break-all;
}
}
}
</style>
@@ -7,6 +7,7 @@ import { Modal, notification } from "ant-design-vue";
import yaml from "js-yaml";
import { usePluginImport } from "./use-import";
import KvInput from "/@/components/plugins/common/kv-input.vue";
import DependPluginsInput from "./components/depend-plugins-input.vue";
import { usePluginConfig } from "./use-config";
import { useSettingStore } from "/src/store/settings/index";
import { usePluginStore } from "/@/store/plugin";
@@ -326,6 +327,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: t("certd.auth"), value: "access" },
{ label: t("certd.dns"), value: "dnsProvider" },
{ label: t("certd.deployPlugin"), value: "deploy" },
{ label: "通知", value: "notification" },
{ label: "Addon", value: "addon" },
],
}),
column: {
@@ -547,7 +550,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
type: "text",
form: {
component: {
name: KvInput,
name: DependPluginsInput,
vModel: "modelValue",
},
helper: t("certd.pluginDependenciesHelper"),
@@ -0,0 +1,246 @@
import { notification } from "ant-design-vue";
import * as api from "./api";
import { usePluginStore } from "/@/store/plugin";
import { useFormDialog } from "/@/use/use-dialog";
import { useI18n } from "/src/locales";
/**
* 在线插件的完整标识(author/name)。
*/
export function fullNameValueFor(plugin: any) {
return (plugin?.fullName || (plugin?.author && plugin?.name ? `${plugin.author}/${plugin.name}` : "")).trim();
}
export function isOnlinePluginMissing(error: unknown) {
return error instanceof Error && error.message.includes("插件不存在");
}
/**
* 在线插件依赖解析与安装,供插件卡片、详情弹窗等安装入口复用。
* 依赖 key 格式:内置插件用 type:name(如 access:aliyun),市场插件用 type:author/name(如 access:greper/hostAccess)。
*/
export function useOnlineInstall() {
const { t } = useI18n();
const pluginStore = usePluginStore();
const { openFormDialog } = useFormDialog();
/**
* 动态打开插件详情对话框(openFormDialog 渲染,不依赖模板嵌套组件)。
*/
function openDependencyDetail(item: any) {
openFormDialog({
title: `${item.title || item.name || item.fullName} - 插件详情`,
columns: {},
noneForm: true,
className: "plugin-dependency-detail-dialog",
wrapper: {
width: 640,
destroyOnClose: true,
maskClosable: true,
okText: t("certd.confirm"),
cancelText: t("certd.cancel"),
},
body: () => (
<div class="plugin-dependency-detail">
<div class="plugin-dependency-detail__item">
<span class="plugin-dependency-detail__label">{t("certd.pluginName")}</span>
<span>{item.title || item.name || "-"}</span>
</div>
<div class="plugin-dependency-detail__item">
<span class="plugin-dependency-detail__label">{t("certd.author")}</span>
<span>{item.author || "-"}</span>
</div>
<div class="plugin-dependency-detail__item">
<span class="plugin-dependency-detail__label">完整标识</span>
<span>{fullNameValueFor(item) || "-"}</span>
</div>
<div class="plugin-dependency-detail__item">
<span class="plugin-dependency-detail__label">{t("certd.pluginType")}</span>
<span>{item.pluginType || "-"}</span>
</div>
<div class="plugin-dependency-detail__item">
<span class="plugin-dependency-detail__label">{t("certd.version")}</span>
<span>{item.latest || item.installedVersion || "-"}</span>
</div>
<div class="plugin-dependency-detail__item">
<span class="plugin-dependency-detail__label">{t("certd.desc")}</span>
<span>{item.desc || "-"}</span>
</div>
</div>
),
async onSubmit() {},
});
}
/**
* 解析插件依赖树(DFS + 循环检测),返回需要先安装的依赖列表(依赖序,已满足的除外)。
* 本地同步数据缺失时,会从平台实时获取插件声明的依赖。
*/
async function resolveOnlinePluginDependencies(target: any) {
const declared = target?.dependPlugins || {};
if (!declared || typeof declared !== "object" || Object.keys(declared).length === 0) {
return [];
}
// 确保本地插件注册表已加载,用于判断依赖是否已被本地内置/已安装插件满足
await pluginStore.init();
const marketList = await api.OnlinePluginList({});
const records = [target, ...(marketList || [])];
const visited = new Set<string>();
const visiting = new Set<string>();
const ordered: any[] = [];
const visit = (record: any, requirement?: string) => {
const identity = fullNameValueFor(record);
if (!identity || visited.has(identity)) {
return;
}
// 目标插件自身即使已安装(升级场景)也要继续向下解析依赖;
// 已满足的依赖(已安装且版本达标,或由本地内置插件提供)直接跳过
if (record !== target && dependencySatisfied(record, requirement)) {
return;
}
if (visiting.has(identity)) {
throw new Error(t("certd.onlinePluginDependencyCycle", { name: identity }));
}
visiting.add(identity);
const dependencies = record?.dependPlugins || {};
for (const [dependencyKey, dependencyVersion] of Object.entries(dependencies)) {
const dependency = findOnlineDependency(records, dependencyKey, dependencyVersion as string);
if (!dependency) {
throw new Error(`${t("certd.onlinePluginDependencyNotFound")}: ${dependencyKey}`);
}
visit(dependency, dependencyVersion as string);
}
visiting.delete(identity);
visited.add(identity);
if (record !== target && !record.installed) {
ordered.push(record);
}
};
visit(target);
return ordered;
}
/**
* 按依赖序安装:先装依赖,再装目标插件;options.version 可指定目标插件版本(详情弹窗安装指定版本场景)。
*/
async function installOnlinePluginChain(dependencies: any[], target: any, options?: { version?: string }) {
for (const dependency of dependencies) {
await installOnlinePluginRecord(dependency);
}
await installOnlinePluginRecord(target, options?.version);
await pluginStore.reload();
notification.success({ message: t("certd.onlinePluginInstallSuccess") });
}
async function installOnlinePluginRecord(record: any, versionOverride?: string) {
const fullName = fullNameValueFor(record);
if (!fullName) {
throw new Error(t("certd.onlinePluginDependencyNotFound"));
}
const requirement = record.__dependencyVersion;
// requirement 只会是具体版本号或 "*"(完整标识匹配时已归一化为 "*"),不会把标识当版本号传
const version = versionOverride || (requirement && requirement !== "*" ? requirement : record.latest);
await api.OnlinePluginInstall(
{
fullName,
version,
},
{
showErrorNotify: false,
}
);
}
function findOnlineDependency(records: any[], dependencyKey: string, dependencyVersion: string) {
// 1) 依赖值直接是插件完整标识(如 tencent/TestAccess):按 fullName 精确匹配
const direct = records.find(item => fullNameValueFor(item) === dependencyVersion);
if (direct) {
// 值是完整标识而非版本号,安装时取该插件最新版本
direct.__dependencyVersion = "*";
return direct;
}
const parts = dependencyKey.split(":");
// 分割出类型前缀(如 access/plugin/dnsProvider/notification/addon),剩下的可能是
// 纯插件名(内置插件),也可能是 author/name 完整标识(市场插件)
const dependencyType = parts.length > 1 ? parts[0] : "";
const dependencyRef = parts.length > 1 ? parts[parts.length - 1] : dependencyKey;
const typeMatches = (item: any) => !dependencyType || item.pluginType === dependencyType || (dependencyType === "plugin" && item.pluginType === "deploy");
// 2) 按完整标识匹配(type:author/name),并校验插件类型与类型前缀一致
const byFullName = records.find(item => typeMatches(item) && fullNameValueFor(item) === dependencyRef);
if (byFullName) {
byFullName.__dependencyVersion = dependencyVersion;
return byFullName;
}
// 3) 再按 type + 纯插件名匹配(内置插件或省略作者的依赖)
const dependencyName = dependencyRef.includes("/") ? dependencyRef.slice(dependencyRef.lastIndexOf("/") + 1) : dependencyRef;
const record = records.find(item => typeMatches(item) && item.name === dependencyName);
if (record) {
// 按 type:name 匹配时,依赖值视为版本要求(* 或具体版本号)
record.__dependencyVersion = dependencyVersion;
return record;
}
// 4) 本地已安装/内置插件已满足依赖(如依赖内置的 access:aliyun),无需提示安装
const localPlugin = findLocalPluginSatisfyingDependency(dependencyType, dependencyName);
if (localPlugin) {
return localPlugin;
}
return undefined;
}
function findLocalPluginSatisfyingDependency(dependencyType: string, dependencyName: string) {
const pluginDefine = pluginStore.getPluginDefineSync(dependencyName);
if (!pluginDefine) {
return undefined;
}
return {
name: dependencyName,
fullName: `local:${dependencyType || "plugin"}:${dependencyName}`,
installed: true,
};
}
function dependencySatisfied(record: any, requirement?: string) {
if (!record.installed) {
return false;
}
const requirementText = (requirement || "").trim();
if (!requirementText || requirementText === "*") {
return true;
}
// 具体版本号要求:已安装版本不低于要求即视为已满足,否则需要重新安装升级
return compareVersions(record.installedVersion, requirementText) >= 0;
}
return {
resolveOnlinePluginDependencies,
installOnlinePluginChain,
installOnlinePluginRecord,
openDependencyDetail,
};
}
/**
* 版本号比较:current > required 返回 1,相等返回 0,小于返回 -1。
*/
export function compareVersions(current?: string, required?: string) {
const normalize = (version?: string) => (version || "").trim().replace(/^[vV]/, "");
const currentParts = normalize(current).split(".");
const requiredParts = normalize(required).split(".");
const maxLength = Math.max(currentParts.length, requiredParts.length);
for (let index = 0; index < maxLength; index++) {
const currentPart = Number(currentParts[index] || 0);
const requiredPart = Number(requiredParts[index] || 0);
if (!Number.isInteger(currentPart) || !Number.isInteger(requiredPart)) {
return 0;
}
if (currentPart > requiredPart) {
return 1;
}
if (currentPart < requiredPart) {
return -1;
}
}
return 0;
}
@@ -13,3 +13,5 @@ SET "full_name" = CASE
ELSE "name"
END
WHERE "full_name" IS NULL OR TRIM("full_name") = '';
ALTER TABLE "cd_domain" ADD COLUMN "remark" varchar(500) NOT NULL DEFAULT '';
@@ -0,0 +1,3 @@
-- 在线插件依赖声明使用独立字段存储,避免塞进 extra YAML 解析合并。
-- 旧数据里 extra 中的 dependPlugins 由代码读取时兜底兼容,下次同步后自动迁移到新字段。
ALTER TABLE "pi_plugin" ADD COLUMN "depend_plugins" varchar(4096);
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getMetadataArgsStorage } from "typeorm";
import { DomainEntity } from "./domain.js";
describe("DomainEntity", () => {
it("应映射域名备注字段", () => {
const remarkColumn = getMetadataArgsStorage().columns.find(column => {
return column.target === DomainEntity && column.propertyName === "remark";
});
assert.ok(remarkColumn);
assert.equal(remarkColumn.options.name, "remark");
});
});
@@ -13,6 +13,9 @@ export class DomainEntity {
@Column({ comment: "主域名", length: 100 })
domain: string;
@Column({ comment: "备注", name: "remark", length: 500, default: "" })
remark: string;
@Column({ comment: "校验类型", name: "challenge_type", length: 50 })
challengeType: string;
@@ -1,6 +1,7 @@
import { Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { accessRegistry, pluginGroups, pluginRegistry } from "@certd/pipeline";
import { accessRegistry, notificationRegistry, pluginGroups, pluginRegistry } from "@certd/pipeline";
import { dnsProviderRegistry } from "@certd/plugin-cert";
import { addonRegistry } from "@certd/lib-server";
import { cloneDeep } from "lodash-es";
@Provide()
@@ -27,7 +28,13 @@ export class BuiltInPluginService {
}
getAllList() {
const pluginList = this.getList();
// 各注册表补 pluginType,供内置插件按类型查询/筛选
const pluginList = this.getList().map(item => {
return {
...item,
pluginType: "deploy",
};
});
const accessList = accessRegistry.getDefineList().map(item => {
return {
...item,
@@ -40,7 +47,19 @@ export class BuiltInPluginService {
pluginType: "dnsProvider",
};
});
const list = [...pluginList, ...accessList, ...dnsProviderList];
const notificationList = notificationRegistry.getDefineList().map(item => {
return {
...item,
pluginType: "notification",
};
});
const addonList = (addonRegistry.getDefineList?.() || []).map(item => {
return {
...item,
pluginType: "addon",
};
});
const list = [...pluginList, ...accessList, ...dnsProviderList, ...notificationList, ...addonList];
return list.sort((a, b) => {
return (a.order ?? 10) - (b.order ?? 10);
});
@@ -50,6 +50,9 @@ export class PluginEntity {
@Column({ comment: "额外配置", length: 40960, nullable: true })
extra: string;
@Column({ name: "depend_plugins", comment: "依赖的其他插件声明(type:name -> 版本或完整标识)", length: 4096, nullable: true })
dependPlugins: string;
@Column({ comment: "作者", length: 100, nullable: true })
author: string;
@@ -190,3 +190,39 @@ return class DemoDnsProvider extends AbstractDnsProvider {
content: script,
};
}
export function getDefaultNotificationPlugin() {
const metadata = `
input: # 通知插件的输入参数
title:
title: 通知标题
component:
name: a-input
required: false
content:
title: 通知内容
component:
name: a-textarea
required: true
`;
const script = `
// 必须使用 await import 来引入模块
const { BaseNotification } = await import("@certd/pipeline")
// 需要返回一个继承BaseNotification的类
return class DemoNotification extends BaseNotification {
// 通知输入参数,对应左边 input 配置
// async send(body){
// // body.title: 通知标题
// // body.content: 通知内容
// // this.logger.info("title:", body.title)
// // 调用第三方 API 发送通知
// }
}
`;
return {
metadata: metadata,
content: script,
};
}
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import yaml from "js-yaml";
import { canEditStorePlugin, getPluginFullName, isOnlinePluginUpgradeAvailable, parseOnlinePluginFullName, PluginService } from "./plugin-service.js";
type RepoOptions = {
@@ -243,6 +244,39 @@ describe("PluginService online plugins", () => {
await assert.rejects(() => service.update({ id: 1, type: "store", author: "", name: "demo" }), /请先填写插件作者/);
});
it("creates a notification plugin with the default template", async () => {
const service = new PluginService();
let savedPlugin: any;
service.repository = {
async findOne() {
return null;
},
async findOneBy() {
return null;
},
async save(plugin: any) {
if (plugin.id == null) {
plugin.id = 1;
}
savedPlugin = plugin;
return plugin;
},
} as any;
service.registerById = async () => {};
const res = await service.add({
type: "store",
author: "greper",
name: "demo-notify",
title: "Demo Notify",
pluginType: "notification",
});
assert.equal(savedPlugin.pluginType, "notification");
assert.match(savedPlugin.content, /BaseNotification/);
assert.equal(typeof res.id, "number");
});
it("marks online plugins with local installation state", async () => {
const service = new PluginService();
service.repository = createPluginRepositoryMock({
@@ -460,6 +494,98 @@ describe("PluginService online plugins", () => {
assert.equal(savedSettings[0].lastSyncTime > 0, true);
});
it("syncs dependPlugins from platform and returns them in the online list", async () => {
const service = new PluginService();
const savedSettings: any[] = [];
service.plusService = {
async register() {},
async request() {
return {
page: {
start: 0,
limit: 200,
total: 2,
},
list: [
{
author: "greper",
name: "TestDeploy",
pluginType: "deploy",
title: "TestDeploy",
latest: "1.0.2",
status: "published",
dependPlugins: {
"access:greper/hostAccess": "*",
},
},
{
author: "greper",
name: "hostAccess",
pluginType: "access",
title: "Host Access",
latest: "1.0.0",
status: "published",
},
],
};
},
} as any;
service.sysSettingsService = {
async getSetting() {
return { lastSyncTime: 0 };
},
async saveSetting(setting: any) {
savedSettings.push(setting);
},
} as any;
const repository = createPluginRepositoryMock({
existingStoreRows: [],
queryRows: [],
});
service.repository = repository as any;
const res = await service.syncOnlinePluginList();
const saved = repository.state.savedRows.find((item: any) => item.fullName === "greper/TestDeploy");
assert.deepEqual(yaml.load(saved.dependPlugins), { "access:greper/hostAccess": "*" });
assert.deepEqual(yaml.load(saved.extra).dependPlugins, { "access:greper/hostAccess": "*" });
// 列表接口同样能返回 dependPlugins,供前端依赖解析匹配 type:author/name
const deployRecord = res.find((item: any) => item.fullName === "greper/TestDeploy");
assert.deepEqual(deployRecord.dependPlugins, { "access:greper/hostAccess": "*" });
const hostRecord = res.find((item: any) => item.fullName === "greper/hostAccess");
assert.equal(hostRecord.fullName, "greper/hostAccess");
});
it("onlinePluginList returns fullName and dependPlugins for dependency matching", async () => {
const service = new PluginService();
const row = {
id: 1,
type: "store",
fullName: "greper/hostAccess",
author: "greper",
name: "hostAccess",
pluginType: "access",
installed: false,
dependPlugins: yaml.dump({ "access:aliyun": "*" }),
};
service.repository = createPluginRepositoryMock({
queryRows: [row],
existingStoreRows: [row],
}) as any;
service.sysSettingsService = {
async getSetting() {
return {};
},
} as any;
const res = await service.onlinePluginList({});
// 前端依赖解析依赖这些字段匹配 type:author/name
assert.equal(res.length, 1);
assert.equal(res[0].fullName, "greper/hostAccess");
assert.deepEqual(res[0].dependPlugins, { "access:aliyun": "*" });
});
it("page includes synced store plugins without installed state", async () => {
const service = new PluginService();
const installedRow = {
@@ -620,6 +746,141 @@ describe("PluginService online plugins", () => {
});
});
it("preserves dependPlugins when syncing online plugin metadata", async () => {
const service = new PluginService();
service.repository = createPluginRepositoryMock() as any;
const record = (service as any).normalizeOnlinePluginRecord(
{
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
dependPlugins: {
"access:aliyun": "developer/aliyun-access",
},
},
123
);
// extra 是依赖声明的主存储,depend_plugins 列是它的冗余,两者都写入
assert.deepEqual(yaml.load(record.extra).dependPlugins, {
"access:aliyun": "developer/aliyun-access",
});
assert.deepEqual(yaml.load(record.dependPlugins), {
"access:aliyun": "developer/aliyun-access",
});
});
it("clears dependPlugins when remote plugin no longer declares dependencies", async () => {
const service = new PluginService();
service.repository = createPluginRepositoryMock() as any;
const old = {
extra: yaml.dump({ dependPlugins: { "access:aliyun": "developer/aliyun-access" } }),
};
const record = (service as any).normalizeOnlinePluginRecord(
{
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
},
123,
old
);
// 远程下掉依赖后,本地旧声明(extra 主存储与列冗余)一并清理
assert.equal(record.dependPlugins, null);
assert.deepEqual(yaml.load(record.extra).dependPlugins, {});
});
it("reads dependPlugins from the dedicated column", async () => {
const service = new PluginService();
const bean = (service as any).toOnlinePluginBean({
id: 1,
type: "store",
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
installed: false,
dependPlugins: yaml.dump({ "access:aliyun": "*" }),
});
assert.deepEqual(bean.dependPlugins, { "access:aliyun": "*" });
});
it("parses dependPlugins with author-qualified colon keys from the column", async () => {
const service = new PluginService();
// 数据库里存的实际格式:access:author/name: version(含冒号的 key)
const bean = (service as any).toOnlinePluginBean({
id: 1,
type: "store",
fullName: "tencent/TestDeploy",
author: "tencent",
name: "TestDeploy",
pluginType: "deploy",
installed: false,
dependPlugins: "access:tencent/TestAccess: '*'\n",
});
assert.deepEqual(bean.dependPlugins, { "access:tencent/TestAccess": "*" });
});
it("falls back to extra dependPlugins for legacy records without the column", async () => {
const service = new PluginService();
const bean = (service as any).toOnlinePluginBean({
id: 1,
type: "store",
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
installed: false,
extra: yaml.dump({ dependPlugins: { "access:aliyun": "*" } }),
});
assert.deepEqual(bean.dependPlugins, { "access:aliyun": "*" });
});
it("writes dependPlugins into the dedicated column when importing plugin content", async () => {
const service = new PluginService();
let savedPlugin: any;
service.repository = {
async findOne() {
return null;
},
async findOneBy() {
return null;
},
async save(plugin: any) {
savedPlugin = plugin;
return plugin;
},
} as any;
service.add = async (param: any) => {
param.id = 1;
return { id: 1 };
};
service.unRegisterById = async () => {};
service.registerById = async () => {};
await service.importPlugin({
type: "store",
override: true,
content: yaml.dump({
name: "demo",
pluginType: "deploy",
author: "developer",
dependPlugins: { "access:aliyun": "*" },
content: "return class Demo {}",
}),
});
// extra 主存储与列冗余都写入
assert.deepEqual(yaml.load(savedPlugin.dependPlugins), { "access:aliyun": "*" });
assert.deepEqual(yaml.load(savedPlugin.extra).dependPlugins, { "access:aliyun": "*" });
});
it("fills missing yaml version from online plugin version before import", async () => {
const service = new PluginService();
let importReq: any;
@@ -1069,4 +1330,182 @@ describe("PluginService online plugins", () => {
assert.deepEqual(deleted, []);
});
it("persists dependPlugins from the edit form (object form) through update", async () => {
const service = new PluginService();
let savedPlugin: any;
service.repository = {
async findOne() {
return null;
},
async save(plugin: any) {
savedPlugin = plugin;
return plugin;
},
} as any;
service.unRegisterById = async () => {};
service.registerById = async () => {};
// 前端编辑表单提交的是 KvInput 的对象形式
await service.update({
id: 7,
type: "store",
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
dependPlugins: {
"access:aliyun": "*",
},
});
assert.deepEqual(yaml.load(savedPlugin.dependPlugins), { "access:aliyun": "*" });
});
it("persists dependPlugins from the edit form (yaml string form) through update", async () => {
const service = new PluginService();
let savedPlugin: any;
service.repository = {
async findOne() {
return null;
},
async save(plugin: any) {
savedPlugin = plugin;
return plugin;
},
} as any;
service.unRegisterById = async () => {};
service.registerById = async () => {};
// 部分提交路径(如表格 Fast Crud submit)可能已经是 YAML 字符串
await service.update({
id: 7,
type: "store",
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
dependPlugins: yaml.dump({ "access:aliyun": "*" }),
});
assert.deepEqual(yaml.load(savedPlugin.dependPlugins), { "access:aliyun": "*" });
});
it("redundantly writes dependPlugins into the column from extra (yaml string form)", async () => {
const service = new PluginService();
let savedPlugin: any;
service.repository = {
async findOne() {
return null;
},
async save(plugin: any) {
savedPlugin = plugin;
return plugin;
},
} as any;
service.unRegisterById = async () => {};
service.registerById = async () => {};
// 前端仍以 extra.dependPlugins 提交(旧方式),extra 是 YAML 字符串
await service.update({
id: 7,
type: "store",
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
extra: yaml.dump({ dependPlugins: { "access:aliyun": "*" } }),
});
assert.deepEqual(yaml.load(savedPlugin.dependPlugins), { "access:aliyun": "*" });
});
it("redundantly writes dependPlugins into the column from extra (object form)", async () => {
const service = new PluginService();
let savedPlugin: any;
service.repository = {
async findOne() {
return null;
},
async save(plugin: any) {
savedPlugin = plugin;
return plugin;
},
} as any;
service.unRegisterById = async () => {};
service.registerById = async () => {};
// 兼容 extra 直接以对象形式提交的路径
await service.update({
id: 7,
type: "store",
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
extra: {
dependPlugins: { "access:aliyun": "*" },
},
});
assert.deepEqual(yaml.load(savedPlugin.dependPlugins), { "access:aliyun": "*" });
});
it("clears the column when extra has no dependPlugins declaration", async () => {
const service = new PluginService();
let savedPlugin: any;
service.repository = {
async findOne() {
return null;
},
async save(plugin: any) {
savedPlugin = plugin;
return plugin;
},
} as any;
service.unRegisterById = async () => {};
service.registerById = async () => {};
await service.update({
id: 7,
type: "store",
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
extra: yaml.dump({ dependPackages: {} }),
});
assert.equal(savedPlugin.dependPlugins, null);
});
it("exports dependPlugins back into the plugin YAML", async () => {
const service = new PluginService();
service.info = (async (id: number) => {
return {
id,
type: "store",
fullName: "developer/demo",
author: "developer",
name: "demo",
pluginType: "deploy",
title: "demo",
icon: "icon",
group: "default",
desc: "desc",
version: "1.0.0",
dependPlugins: yaml.dump({ "access:aliyun": "*" }),
} as any;
}) as any;
const content = await service.exportPlugin(7);
const loaded = yaml.load(content) as Record<string, any>;
assert.deepEqual(loaded.dependPlugins, { "access:aliyun": "*" });
});
});
@@ -1,4 +1,4 @@
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { addonRegistry, BaseService, PageReq, PlusService, SysInstallInfo, SysPluginSetting, SysSettingsService } from "@certd/lib-server";
import { PluginEntity } from "../entity/plugin.js";
import { InjectEntityModel } from "@midwayjs/typeorm";
@@ -9,7 +9,7 @@ import { merge } from "lodash-es";
import { dnsProviderRegistry } from "@certd/plugin-cert";
import { logger } from "@certd/basic";
import yaml from "js-yaml";
import { getDefaultAccessPlugin, getDefaultDeployPlugin, getDefaultDnsPlugin } from "./default-plugin.js";
import { getDefaultAccessPlugin, getDefaultDeployPlugin, getDefaultDnsPlugin, getDefaultNotificationPlugin } from "./default-plugin.js";
import fs from "fs";
import path from "path";
import { importRuntime as importRuntimeDirect, getRuntimeDepsService, pluginRegistry, accessRegistry, notificationRegistry } from "@certd/pipeline";
@@ -112,6 +112,7 @@ export type OnlinePluginBean = {
score?: number;
aiCheckStatus?: string;
vip?: string;
dependPlugins?: Record<string, string>;
editable?: boolean;
selfAuthored?: boolean;
installed?: boolean;
@@ -219,6 +220,38 @@ function fillOnlinePluginYamlVersion(content: string, version?: string) {
);
}
/**
* 把依赖插件声明序列化为 YAML 字符串;空依赖返回 null,不落库。
*/
function toDependPluginsYaml(dependPlugins?: Record<string, string> | null): string | null {
if (!dependPlugins || typeof dependPlugins !== "object" || Array.isArray(dependPlugins) || Object.keys(dependPlugins).length === 0) {
return null;
}
return yaml.dump(dependPlugins);
}
/**
* 解析依赖插件声明。depend_plugins 字段存的是 YAML 字符串;
* 兼容直接传入对象(如内置插件本地 YAML 注册)和旧数据。
*/
function parseDependPlugins(value?: string | Record<string, string> | null): Record<string, string> {
if (!value) {
return {};
}
if (typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, string>;
}
try {
const parsed = yaml.load(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, string>;
}
} catch (e) {
// 解析失败按无依赖处理,避免脏数据阻断注册
}
return {};
}
function isBareModuleSpecifier(modulePath: string) {
if (modulePath.startsWith(".") || modulePath.startsWith("/") || modulePath.startsWith("file:") || modulePath.startsWith("node:")) {
return false;
@@ -565,6 +598,7 @@ export class PluginService extends BaseService<PluginEntity> {
fullName: item.name,
title: item.title,
type: "builtIn",
pluginType: item.pluginType,
icon: item.icon,
desc: item.desc,
group: item.group,
@@ -600,6 +634,46 @@ export class PluginService extends BaseService<PluginEntity> {
return this.builtInPluginService.getByType(type);
}
/**
* 表单可能直接提交对象形式的 dependPlugins(KvInput),落库前统一序列化为 YAML 字符串。
*/
private normalizeDependPluginsParam(param: any) {
if (param.dependPlugins && typeof param.dependPlugins === "object" && !Array.isArray(param.dependPlugins)) {
param.dependPlugins = toDependPluginsYaml(param.dependPlugins);
}
}
/**
* 前端仍以 extra.dependPlugins 作为依赖声明的主存储提交(旧提交方式不变),
* depend_plugins 列作为它的冗余,这里从 extra 取值写入列,保证列表/依赖树可以快速读取。
*/
private normalizeDependPluginsFromExtra(param: any) {
let extra: any = param.extra;
if (typeof extra === "string") {
try {
extra = yaml.load(extra) || {};
} catch (e) {
extra = undefined;
}
}
const extraDependPlugins = extra && typeof extra === "object" && !Array.isArray(extra) ? extra.dependPlugins : undefined;
if (extraDependPlugins != null) {
// 表单以 extra.dependPlugins 提交:冗余写入列
if (typeof extraDependPlugins === "object" && !Array.isArray(extraDependPlugins)) {
param.dependPlugins = toDependPluginsYaml(extraDependPlugins);
} else if (typeof extraDependPlugins === "string") {
param.dependPlugins = extraDependPlugins;
}
} else if (param.dependPlugins == null) {
// extra 中没有依赖声明、也没有顶层提交,列与主存储保持一致(清空)
param.dependPlugins = null;
}
// extra 以对象形式提交(部分提交路径未序列化)时,统一转回字符串,避免落库成 [object Object]
if (param.extra && typeof param.extra === "object" && !Array.isArray(param.extra)) {
param.extra = yaml.dump(param.extra);
}
}
private normalizeStorePluginAuthor(plugin: Record<string, any>) {
const author = `${plugin.author || ""}`.trim();
if (!author || author.toLowerCase() === "local") {
@@ -617,6 +691,8 @@ export class PluginService extends BaseService<PluginEntity> {
* @param param 数据
*/
async add(param: any) {
this.normalizeDependPluginsParam(param);
this.normalizeDependPluginsFromExtra(param);
param.type = normalizePluginSourceType(param.type);
if (param.type === "store") {
this.normalizeStorePluginAuthor(param);
@@ -647,6 +723,9 @@ export class PluginService extends BaseService<PluginEntity> {
} else if (param.pluginType === "dnsProvider") {
plugin = getDefaultDnsPlugin();
delete param.group;
} else if (param.pluginType === "notification") {
plugin = getDefaultNotificationPlugin();
delete param.group;
} else {
throw new Error(`插件类型${param.pluginType}不支持`);
}
@@ -684,6 +763,17 @@ export class PluginService extends BaseService<PluginEntity> {
private normalizeOnlinePluginRecord(item: OnlinePluginBean, syncTime: number, old?: PluginEntity) {
const fullName = this.getOnlinePluginFullName(item);
let extra: Record<string, any> = {};
if (old?.extra) {
try {
extra = (yaml.load(old.extra) as Record<string, any>) || {};
} catch (e) {
extra = {};
}
}
// extra 是依赖声明的主存储,同步时以远程为准覆盖;depend_plugins 列是其冗余。
// 远程下掉依赖后本地旧的 dependPlugins 一并清理,不再残留。
extra.dependPlugins = item.dependPlugins || {};
return this.repository.create({
id: old?.id,
appId: item.appId,
@@ -702,6 +792,8 @@ export class PluginService extends BaseService<PluginEntity> {
score: item.score,
aiCheckStatus: item.aiCheckStatus,
vip: item.vip,
dependPlugins: toDependPluginsYaml(item.dependPlugins),
extra: Object.keys(extra).length > 0 ? yaml.dump(extra) : null,
syncTime,
type: old?.type || "store",
disabled: old?.disabled ?? false,
@@ -710,7 +802,6 @@ export class PluginService extends BaseService<PluginEntity> {
sysSetting: old?.sysSetting,
content: old?.content,
metadata: old?.metadata,
extra: old?.extra,
version: old?.version,
updateTime: new Date(),
});
@@ -718,6 +809,17 @@ export class PluginService extends BaseService<PluginEntity> {
private toOnlinePluginBean(item: PluginEntity): OnlinePluginBean {
const isInstalled = item.installed === true;
// 优先读取独立字段 depend_plugins;旧数据里依赖声明可能还残留在 extra 中,作为兜底。
const dependPlugins = parseDependPlugins(item.dependPlugins);
let legacyDependPlugins: Record<string, string> = {};
if (Object.keys(dependPlugins).length === 0 && item.extra) {
try {
const extra = (yaml.load(item.extra) as { dependPlugins?: Record<string, string> }) || {};
legacyDependPlugins = extra.dependPlugins || {};
} catch (e) {
legacyDependPlugins = {};
}
}
return {
id: item.id,
appId: item.appId,
@@ -737,6 +839,7 @@ export class PluginService extends BaseService<PluginEntity> {
score: item.score,
aiCheckStatus: item.aiCheckStatus,
vip: item.vip,
dependPlugins: Object.keys(dependPlugins).length > 0 ? dependPlugins : legacyDependPlugins,
syncTime: item.syncTime,
installed: isInstalled,
localPluginId: isInstalled ? item.id : undefined,
@@ -1219,6 +1322,8 @@ export class PluginService extends BaseService<PluginEntity> {
}
async update(param: any) {
this.normalizeDependPluginsParam(param);
this.normalizeDependPluginsFromExtra(param);
param.type = normalizePluginSourceType(param.type);
if (param.type === "store") {
this.normalizeStorePluginAuthor(param);
@@ -1360,6 +1465,14 @@ export class PluginService extends BaseService<PluginEntity> {
delete item.metadata;
delete item.content;
delete item.extra;
// depend_plugins 字段存的是 YAML 字符串,注册时解析为对象供运行时使用;
// 旧数据里依赖声明可能还在 extra 中({...extra} 已合并),这里统一兜底。
const columnDependPlugins = parseDependPlugins(plugin.dependPlugins);
if (Object.keys(columnDependPlugins).length > 0) {
item.dependPlugins = columnDependPlugins;
} else if (typeof item.dependPlugins !== "object" || item.dependPlugins == null) {
item.dependPlugins = {};
}
const fullName = getPluginFullName(item);
item.name = fullName;
const name = getPluginRegistryName(item);
@@ -1415,8 +1528,9 @@ export class PluginService extends BaseService<PluginEntity> {
if (!info) {
throw new Error("插件不存在");
}
const metadata = yaml.load(info.metadata || "");
const extra = yaml.load(info.extra || "");
// yaml.load 空字符串会返回 undefined,统一兜底为空对象,避免展开/取值崩溃
const metadata = (yaml.load(info.metadata || "") as Record<string, any>) || {};
const extra = (yaml.load(info.extra || "") as Record<string, any>) || {};
const content = info.content;
delete info.metadata;
delete info.extra;
@@ -1430,6 +1544,13 @@ export class PluginService extends BaseService<PluginEntity> {
...extra,
content,
};
// depend_plugins 字段存的是 YAML 字符串,导出时恢复为对象;旧数据可能在 extra 中。
const columnDependPlugins = parseDependPlugins(info.dependPlugins);
const legacyDependPlugins = extra.dependPlugins && typeof extra.dependPlugins === "object" ? extra.dependPlugins : undefined;
plugin.dependPlugins = Object.keys(columnDependPlugins).length > 0 ? columnDependPlugins : legacyDependPlugins;
if (!plugin.dependPlugins) {
delete plugin.dependPlugins;
}
return yaml.dump(plugin) as string;
}
@@ -1478,6 +1599,7 @@ export class PluginService extends BaseService<PluginEntity> {
output: loaded.output,
};
const extra = {
// extra 作为依赖声明的主存储(前端编辑表单读取),depend_plugins 列是其冗余
dependPlugins: loaded.dependPlugins,
dependPackages: loaded.dependPackages,
default: loaded.default,
@@ -1490,6 +1612,7 @@ export class PluginService extends BaseService<PluginEntity> {
developerId: entityType === "store" ? old?.developerId ?? loaded.developerId : loaded.developerId,
fullName: entityType === "store" ? fullName || old?.fullName : old?.fullName,
type: entityType,
dependPlugins: toDependPluginsYaml(loaded.dependPlugins),
metadata: yaml.dump(metadata),
extra: yaml.dump(extra),
content: loaded.content,