/* 双端判定:SingBox 用 JSON 清单与 JSON 解析,Clash 用 YAML */ const IS_GFS = Plugins.APP_TITLE.includes('SingBox') const DEFAULT_BASE_URL = 'https://raw.githubusercontent.com/GUI-for-Cores/Plugin-Hub/refs/heads/main/plugins/Resources/plugin-config-template-hub/' const README_FILE = 'README.md' /* 清单 diff 时参与比对的字段 */ const TRACKED_FIELDS = ['name', 'description', 'author', 'version', 'tags', 'group', 'homepage', 'updatedAt', 'notes'] const REQUIRED_FIELDS = ['id', 'name', 'description', 'author'] const normalizeError = (err) => (err instanceof Error ? err.message : String(err)) const parseVersion = (v) => { const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(v).trim()) return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null } const isVersionLower = (a, b) => { const pa = parseVersion(a) const pb = parseVersion(b) if (!pa || !pb) { return false } for (let i = 0; i < 3; i++) { if (pa[i] !== pb[i]) { return pa[i] < pb[i] } } return false } const manifestFingerprint = (t) => JSON.stringify(TRACKED_FIELDS.map((k) => t[k] ?? '')) const diffManifests = (oldList, newList) => { const oldMap = new Map(oldList.map((t) => [t.id, t])) const newIds = new Set(newList.map((t) => t.id)) const added = newList.filter((t) => !oldMap.has(t.id)) const removed = oldList.filter((t) => !newIds.has(t.id)) const changed = newList.flatMap((t) => { const prev = oldMap.get(t.id) if (!prev || manifestFingerprint(prev) === manifestFingerprint(t)) { return [] } return [{ from: prev, to: t }] }) return { added, removed, changed } } const isTemplateMeta = (item) => { if (!item || typeof item !== 'object') { return false } const meta = item return REQUIRED_FIELDS.every((k) => typeof meta[k] === 'string' && meta[k] !== '') } /* 读取 records.json 后统一规整为记录数组:兼容旧版单条格式,剔除非法条目 */ const normalizeRecords = (raw) => { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return {} } const isRecord = (it) => typeof it === 'object' && it !== null && typeof it['profileId'] === 'string' const out = {} for (const [id, val] of Object.entries(raw)) { const list = (Array.isArray(val) ? val : [val]).filter(isRecord) if (list.length > 0) { out[id] = list } } return out } /** @type {EsmPlugin} */ export default (plugin) => { const BASE_DIR = `data/third/${plugin.id}` const RECORDS_PATH = `${BASE_DIR}/records.json` const MANIFEST_PATH = `${BASE_DIR}/manifest.json` const README_PATH = `${BASE_DIR}/${README_FILE}` const TEMPLATE_DIR = IS_GFS ? 'gfs' : 'gfc' const TEMPLATE_EXT = IS_GFS ? 'json' : 'yaml' const convertTip = IS_GFS ? '导入会将模板转换为应用托管配置:应用保留入站、出站分组、路由规则、DNS 等支持段;分组内节点按 tag 与现有订阅匹配,未匹配到的节点与引用它们的规则出站会丢失或为空。' : '导入会将模板转换为应用托管配置:仅保留内置出站与分组间引用,模板内嵌节点及其规则会被丢弃;general、tun、dns、hosts、sniffer 等支持段正常保留。' const profilesStore = Plugins.useProfilesStore() const templates = Vue.ref([]) const records = Vue.ref({}) const manifestLoading = Vue.ref(false) const manifestError = Vue.ref('') /* 模板正文会话内缓存,避免重复拉取 */ const templateContentCache = new Map() const readConfigString = (key) => { const raw = plugin[key] return typeof raw === 'string' ? raw.trim() : '' } const headerEnabled = () => { const raw = plugin.ShowHeaderAction return raw !== false && String(raw) !== 'false' } const resourceBase = () => { const custom = readConfigString('BaseUrl') const base = custom || DEFAULT_BASE_URL return base.endsWith('/') ? base : `${base}/` } const getSubscribeIds = () => { const store = Plugins.useSubscribesStore() return (store.subscribes ?? []).map((s) => s.id) } /* 双端 restoreProfile 签名不同,分支内分别以各自签名调用 */ const restoreGfsProfile = (config, name, options = {}) => Plugins.restoreProfile(config, name, options) const restoreGfcProfile = (config, subID) => Plugins.restoreProfile(config, subID, {}, {}) const ensureDir = async (dir) => { if (!(await Plugins.FileExists(dir))) { await Plugins.MakeDir(dir) } } /* 工具栏按钮需以渲染函数构造,组件在模态渲染上下文内解析 */ const uiButton = (props, label) => Vue.h(Vue.resolveComponent('Button'), props, () => label) const saveRecords = async () => { await ensureDir(BASE_DIR) await Plugins.WriteFile(RECORDS_PATH, JSON.stringify(records.value, null, 2)) } const loadLocalData = async () => { try { const content = await Plugins.ReadFile(RECORDS_PATH) records.value = normalizeRecords(JSON.parse(content)) } catch { records.value = {} } if (templates.value.length > 0) { return } try { const content = await Plugins.ReadFile(MANIFEST_PATH) const cached = JSON.parse(content) if (Array.isArray(cached)) { templates.value = cached.filter(isTemplateMeta) } } catch { /* 首次使用无缓存清单 */ } } /* 贡献指南优先读本地缓存,缺失时拉取并落盘 */ const loadReadme = async (force = false) => { if (!force) { try { return await Plugins.ReadFile(README_PATH) } catch { /* 无本地缓存 */ } } const { body, status } = await Plugins.Requests({ method: 'GET', url: `${resourceBase()}${README_FILE}`, autoTransformBody: false }) if (status !== 200) { throw new Error(`HTTP ${status}`) } await ensureDir(BASE_DIR) await Plugins.WriteFile(README_PATH, body) return body } const openReadme = () => { const { defineComponent, ref } = Vue const content = ref('') const loading = ref(true) const error = ref('') const load = async (force = false) => { loading.value = true error.value = '' try { content.value = await loadReadme(force) } catch (err) { error.value = normalizeError(err) } finally { loading.value = false } } const component = defineComponent({ template: `
`, setup() { return { content, loading, error, reload: () => void load(true) } } }) const modal = Plugins.modal({ title: '模板贡献指南', submit: false, cancelText: '关闭', width: '80', height: '80', px: 0, py: 0 }) modal.setContent(component) modal.open() void load() } const fetchManifest = async () => { const { body, status } = await Plugins.Requests({ method: 'GET', url: `${resourceBase()}${IS_GFS ? 'gfs' : 'gfc'}.json`, autoTransformBody: false }) if (status !== 200) { throw new Error(`HTTP ${status}`) } const list = JSON.parse(body) if (!Array.isArray(list)) { throw new Error('清单格式错误:应为数组') } return list.filter((item) => { if (isTemplateMeta(item)) { return true } console.warn(`[${plugin.id}] 清单条目缺少必填字段,已跳过:${JSON.stringify(item)}`) return false }) } /* 远程模板已不存在时删除整条数据;特定配置已删除时仅删除对应条目 */ const purgeStaleRecords = async (list) => { const ids = new Set(list.map((t) => t.id)) let changed = false for (const id of Object.keys(records.value)) { const recs = records.value[id] if (!recs || recs.length === 0) { delete records.value[id] changed = true continue } if (!ids.has(id)) { delete records.value[id] changed = true continue } const kept = recs.filter((rec) => profilesStore.getProfileById(rec.profileId)) if (kept.length === 0) { delete records.value[id] changed = true } else if (kept.length !== recs.length) { records.value[id] = kept changed = true } } if (changed) { await Plugins.WriteFile(RECORDS_PATH, JSON.stringify(records.value, null, 2)) console.warn(`[${plugin.id}] 已清理失效的导入记录`) } } const showDiffAlert = (diff) => { const lines = [] if (diff.added.length > 0) { lines.push([`### 新增(${diff.added.length})`, ...diff.added.map((t) => `- **${t.name}** \`${t.id}\`${t.version ? ` ${t.version}` : ''}`)].join('\n')) } if (diff.removed.length > 0) { lines.push([`### 删除(${diff.removed.length})`, ...diff.removed.map((t) => `- **${t.name}** \`${t.id}\``)].join('\n')) } if (diff.changed.length > 0) { lines.push( [ `### 变更(${diff.changed.length})`, ...diff.changed.map(({ from, to }) => `- **${to.name}** \`${to.id}\`:${from.version || '无版本'} → ${to.version || '无版本'}`) ].join('\n') ) } void Plugins.alert('模板清单有更新', lines.join('\n\n'), { type: 'markdown' }).catch(() => undefined) } const updateManifest = async () => { if (manifestLoading.value) { return } manifestLoading.value = true manifestError.value = '' try { const list = await fetchManifest() const previous = templates.value templates.value = list await ensureDir(BASE_DIR) await Plugins.WriteFile(MANIFEST_PATH, JSON.stringify(list, null, 2)) await purgeStaleRecords(list) if (previous.length > 0) { const diff = diffManifests(previous, list) if (diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0) { showDiffAlert(diff) } } } catch (err) { const msg = normalizeError(err) if (templates.value.length === 0) { manifestError.value = msg } Plugins.message.error(`模板清单更新失败:${msg}`) } finally { manifestLoading.value = false } } /* force 为真时跳过会话缓存,用于「重新加载」 */ const fetchTemplateText = async (tpl, force = false) => { const cached = force ? undefined : templateContentCache.get(tpl.id) if (cached !== undefined) { return cached } const { body, status } = await Plugins.Requests({ method: 'GET', url: `${resourceBase()}${TEMPLATE_DIR}/${tpl.id}.${TEMPLATE_EXT}`, autoTransformBody: false }) if (status !== 200) { throw new Error(`HTTP ${status}`) } templateContentCache.set(tpl.id, body) return body } /* 解析失败时报错并返回 null,由调用方中止流程 */ const parseTemplate = (text) => { try { const parsed = IS_GFS ? JSON.parse(text) : Plugins.YAML.parse(text) if (!parsed || typeof parsed !== 'object') { throw new Error('模板内容不是有效的配置对象') } return parsed } catch (err) { Plugins.message.error(`模板解析失败:${normalizeError(err)}`) return null } } /* 转换损失确认弹窗(PRD §7.2 文案) */ const confirmImport = async (tpl) => { try { return await Plugins.confirm( '导入模板', [ `「${tpl.name}」将被转换为应用托管配置。应用仅保留其支持的配置段(入站、出站分组、路由规则、DNS 等),模板中的其余内容不会原样保留:`, IS_GFS ? '· SingBox:分组内节点按 tag 与现有订阅匹配,未匹配到的节点与引用它们的规则出站会丢失或为空;' : '· Clash:仅保留内置出站与分组间引用,模板内嵌节点及其规则会被丢弃;', '导入后请打开配置编辑器检查并补全节点引用。确定要导入吗?' ].join('\n'), { type: 'text', okText: '仍要导入' } ) } catch { return false } } /* overrideText 为详情页编辑后的模板正文,缺省时按需拉取远程模板 */ const doImport = async (tpl, importing, overrideText) => { if (!(await confirmImport(tpl))) { return } importing.value = true try { const text = overrideText ?? (await fetchTemplateText(tpl).catch((err) => { Plugins.message.error(`模板获取失败:${normalizeError(err)}`) return '' })) if (text === '') { return } const parsed = parseTemplate(text) if (parsed === null) { return } const profile = IS_GFS ? restoreGfsProfile(parsed, tpl.name, { subscriptionIds: getSubscribeIds() }) : restoreGfcProfile(parsed, Plugins.sampleID()) if (!IS_GFS) { profile['name'] = tpl.name } await profilesStore.addProfile(profile) ;(records.value[tpl.id] ??= []).push({ profileId: profile['id'], importedAt: Date.now(), version: tpl.version ?? '' }) await saveRecords() Plugins.message.success(`已导入「${tpl.name}」,请在配置列表查看`) } catch (err) { Plugins.message.error(`导入失败:${normalizeError(err)}`) } finally { importing.value = false } } /* 合并更新:与导入一样优先使用预览页的临时修改内容,转换后按深合并保留已导入配置 */ const doMergeUpdate = async (tpl, record, importing, overrideText) => { const oldProfile = profilesStore.getProfileById(record.profileId) if (!oldProfile) { Plugins.message.error('未找到已导入的配置') return } let ok = false try { ok = await Plugins.confirm( '合并更新', [ `模板「${tpl.name}」已有新版本(${record.version || '未知版本'} → ${tpl.version || '未知版本'})。`, overrideText === undefined ? '将拉取最新模板并转换为托管配置,再与当前已导入配置递归深合并:同名字段以已导入配置为准(保留你的修改),模板新增的字段会补入。' : '将使用当前预览内容(含你的临时修改)转换为托管配置,再与当前已导入配置递归深合并:同名字段以已导入配置为准(保留你的修改),模板新增的字段会补入。', '确定要合并更新吗?' ].join('\n'), { type: 'text', okText: '合并更新' } ) } catch { ok = false } if (!ok) { return } importing.value = true try { const text = overrideText ?? (await fetchTemplateText(tpl).catch((err) => { Plugins.message.error(`模板获取失败:${normalizeError(err)}`) return '' })) if (text === '') { return } const parsed = parseTemplate(text) if (parsed === null) { return } const fresh = IS_GFS ? restoreGfsProfile(parsed, oldProfile['name'], { subscriptionIds: getSubscribeIds() }) : restoreGfcProfile(parsed, oldProfile['id']) const merged = Plugins.deepAssign(fresh, Plugins.deepClone(oldProfile)) merged['id'] = oldProfile['id'] await profilesStore.editProfile(oldProfile['id'], merged) record.importedAt = Date.now() record.version = tpl.version ?? '' await saveRecords() Plugins.message.success(`已合并更新「${tpl.name}」`) } catch (err) { Plugins.message.error(`合并更新失败:${normalizeError(err)}`) } finally { importing.value = false } } const openDetail = (tpl) => { const { computed, defineComponent, ref } = Vue const content = ref('') const originalContent = ref('') const contentLoading = ref(false) const contentError = ref('') const importing = ref(false) const contentDirty = computed(() => content.value !== originalContent.value) const recordList = computed(() => records.value[tpl.id] ?? []) const importEntries = computed(() => recordList.value.map((rec) => { const profile = profilesStore.getProfileById(rec.profileId) return { rec, profile, canMerge: profile !== undefined && isVersionLower(rec.version, tpl.version ?? '') } }) ) const loadContent = async (force = false) => { contentLoading.value = true contentError.value = '' try { const text = await fetchTemplateText(tpl, force) content.value = text originalContent.value = text } catch (err) { contentError.value = normalizeError(err) Plugins.message.error(`模板获取失败:${contentError.value}`) } finally { contentLoading.value = false } } /* 详情打开即预取正文,避免首个编辑器实例挂载后才开始请求 */ void loadContent() const resetContent = () => { content.value = originalContent.value } /* 预览与编辑独立成窗口:详情只保留元信息与操作,避免上下挤压 */ const openEditor = () => { const editor = defineComponent({ template: `
`, setup() { return { content, contentLoading, contentError, importing, lang: IS_GFS ? 'json' : 'yaml', reloadContent: () => void loadContent(true) } } }) const editorModal = Plugins.modal({ title: `预览 / 编辑 — ${tpl.name}`, submit: false, cancelText: '关闭', width: '90', height: '90', px: 12, py: 12 }) editorModal.setContent(editor) editorModal.patchSlots({ toolbar: () => [ contentDirty.value ? uiButton( { type: 'text', size: 'small', icon: 'refresh', onClick: resetContent }, '重置修改' ) : undefined, uiButton( { type: 'text', size: 'small', icon: 'refresh', loading: contentLoading.value, onClick: () => void loadContent(true) }, '重新加载' ) ], action: () => Vue.h('span', { class: 'text-12 mr-auto' }, contentDirty.value ? '已修改,导入与合并更新将使用当前内容' : '') }) editorModal.open() } const component = defineComponent({ template: `
{{ tpl.name }} {{ tpl.version }} {{ tag }}
已修改
{{ tpl.author }} 更新于 {{ tpl.updatedAt }}
简介:{{ tpl.description }}
注意事项:{{ tpl.notes }}
已导入配置 {{ importEntries.length }} 份
尚未导入此模板
导入的配置已不存在,可重新导入
转换提示:{{ convertTip }}
模板内容加载中… 模板内容加载失败:{{ contentError }} 导入会新建一份配置,不影响当前配置
`, setup() { return { tpl, contentLoading, contentError, importing, importEntries, convertTip, contentDirty, openEditor, doImport: () => void doImport(tpl, importing, content.value || undefined), doMergeUpdate: (rec) => void doMergeUpdate(tpl, rec, importing, content.value || undefined), formatTime: (ts) => Plugins.formatDate(ts, 'YYYY-MM-DD HH:mm:ss') } } }) const modal = Plugins.modal({ title: tpl.name, submit: false, cancelText: '关闭', width: '80', height: '80', px: 0, py: 0 }) modal.setContent(component) modal.patchSlots({ toolbar: () => [ tpl.homepage ? uiButton( { type: 'link', size: 'small', icon: 'link', onClick: () => { if (tpl.homepage) { void Plugins.OpenURI(tpl.homepage) } } }, '来源' ) : undefined ] }) modal.open() } const openHub = async () => { const { computed, defineComponent, ref } = Vue await loadLocalData() void updateManifest() const keywords = ref('') const groups = computed(() => { const map = {} templates.value.forEach((t) => { const name = t.group || '通用' ;(map[name] ??= []).push(t) }) return Object.keys(map).map((name) => ({ name, list: map[name] })) }) const filteredGroups = computed(() => { const kw = keywords.value.trim() if (!kw) { return groups.value } const match = Plugins.createTextMatcher(kw.toLocaleLowerCase(), '') return groups.value .map((g) => ({ name: g.name, list: g.list.filter((t) => match([t.name, t.description, t.author, (t.tags ?? []).join('')].join('').toLocaleLowerCase())) })) .filter((g) => g.list.length > 0) }) const component = defineComponent({ template: `
`, setup() { return { keywords, filteredGroups, total: computed(() => templates.value.length), manifestLoading, manifestError, importCount: (id) => records.value[id]?.length ?? 0, refresh: () => void updateManifest(), openDetail } } }) const modal = Plugins.modal({ title: '配置模板中心', submit: false, cancelText: '关闭', width: '90', height: '90' }) modal.setContent(component) modal.patchSlots({ toolbar: () => uiButton( { type: 'link', size: 'small', icon: 'log', onClick: openReadme }, '贡献指南' ) }) modal.open() } /* 触发器 手动触发 */ const onRun = async () => { await openHub() return 0 } /* 触发器 插件卡片菜单 */ const OpenHub = async () => { await openHub() return 0 } const addHeaderAction = () => { Plugins.useAppStore().addCustomActions('profiles_header', { id: plugin.id, component: 'Button', componentProps: { type: 'link', onClick: () => void openHub() }, componentSlots: { default: '模板中心' } }) } /* 触发器 APP就绪后 */ const onReady = async () => { if (headerEnabled()) { addHeaderAction() } return 0 } /* 触发器 配置更新时 */ const onConfigure = async () => { Plugins.useAppStore().removeCustomActions('profiles_header', plugin.id) if (headerEnabled()) { addHeaderAction() } return 0 } /* 触发器 插件销毁时 */ const onDispose = () => { Plugins.useAppStore().removeCustomActions('profiles_header', plugin.id) } /* 触发器 安装插件时 */ const onInstall = async () => { await ensureDir(BASE_DIR) return 0 } /* 触发器 卸载插件时 */ const onUninstall = async () => { await Plugins.ignoredError(Plugins.RemoveFile, BASE_DIR) return 0 } return { onRun, OpenHub, onReady, onConfigure, onDispose, onInstall, onUninstall } }