mirror of
https://github.com/certd/certd.git
synced 2026-10-13 15:49:25 +08:00
Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev
This commit is contained in:
@@ -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>
|
||||
+69
-10
@@ -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,
|
||||
|
||||
+11
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user