fix(webui): 处理市场页与内置插件文案的审查意见

- 设置页与市场页通过 pluginSetVersion 在再次进入时互相同步;刷新逻辑合并为 refreshPluginState
- 安装成功后本地更新 installed_version,不再重新下载目录
- 选中的插件变化时关闭确认栏,避免装错插件
- 本机版本比目录新时不提示更新
- 内置插件文案只用于 source 为 builtin 的插件
- README:链接中的行内代码、段落中的“2024.”、分隔线、标题中的“C#”
This commit is contained in:
Estrella Pan
2026-10-09 15:34:31 +02:00
parent 7ace0581e0
commit c67a63267f
10 changed files with 149 additions and 73 deletions

View File

@@ -9,8 +9,7 @@ import PluginSchemaForm from './plugin-schema-form.vue';
import type { PluginInfo, PluginsOverview } from '#/plugins';
import { apiPlugins } from '@/api/plugins';
import { useConfirm } from '@/hooks/useConfirm';
import { refreshPluginProviders } from '@/hooks/usePluginProviders';
import { refreshPluginUi } from '@/hooks/usePluginUi';
import { pluginSetVersion, refreshPluginState } from '@/hooks/usePluginRefresh';
import { idLabel } from '@/utils/id-label';
import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema';
import {
@@ -27,13 +26,13 @@ const message = useMessage();
const router = useRouter();
const { confirm } = useConfirm();
const configStore = useConfigStore();
const { refreshGroup } = configStore;
const overview = ref<PluginsOverview | null>(null);
const drafts = ref<Record<string, Record<string, unknown>>>({});
// 并发操作各自占用一个 key,互不覆盖对方的加载态
const busy = ref(new Set<string>());
const loadError = ref(false);
let seenVersion = pluginSetVersion.value;
const stateType = {
active: 'success',
@@ -84,12 +83,8 @@ async function run(
busy.value.add(key);
try {
apply(await action());
// 只刷新插件卡片保存的字段,保留未保存的下载器实例与 slots 修改
await refreshGroup('plugins', ['allow_unsigned', 'enabled', 'options']);
// 插件启停会增减下载器/通知渠道候选,同步刷新下拉框
await refreshPluginProviders();
// 启停也会增减插件的前端挂载点
await refreshPluginUi();
await refreshPluginState();
seenVersion = pluginSetVersion.value;
return true;
} catch {
// 后端的具体原因(如验签失败)由 axios 拦截器另行提示
@@ -139,7 +134,7 @@ async function setEnabled(plugin: PluginInfo, enabled: boolean) {
impact &&
!(await confirm({
title: t('config.plugins_set.disable_confirm_title', {
name: pluginName(t, plugin.id, plugin.name),
name: pluginName(t, plugin),
}),
body: impact,
confirmText: t('config.plugins_set.disable'),
@@ -163,7 +158,7 @@ async function saveOptions(plugin: PluginInfo) {
async function uninstall(plugin: PluginInfo) {
const confirmed = await confirm({
title: t('config.plugins_set.uninstall_confirm_title', {
name: pluginName(t, plugin.id, plugin.name),
name: pluginName(t, plugin),
}),
body: [impactText(plugin), t('config.plugins_set.uninstall_confirm_body')]
.filter(Boolean)
@@ -181,6 +176,13 @@ async function uninstall(plugin: PluginInfo) {
}
onMounted(load);
// 设置页被 KeepAlive 缓存:插件市场安装或更新后,再次进入时重新加载列表
onActivated(() => {
if (seenVersion === pluginSetVersion.value) return;
seenVersion = pluginSetVersion.value;
return load();
});
</script>
<template>
@@ -219,7 +221,7 @@ onMounted(load);
>
<header class="plugin__header">
<div class="plugin__title">
<strong>{{ pluginName(t, plugin.id, plugin.name) }}</strong>
<strong>{{ pluginName(t, plugin) }}</strong>
<span class="plugin__meta"
>{{ plugin.id }} · v{{ plugin.version }}</span
>
@@ -229,7 +231,7 @@ onMounted(load);
:loading="busy.has(plugin.id)"
:aria-label="
$t('config.plugins_set.enabled_for', {
name: pluginName(t, plugin.id, plugin.name),
name: pluginName(t, plugin),
})
"
@update:model-value="setEnabled(plugin, $event)"
@@ -247,7 +249,7 @@ onMounted(load);
</div>
<p v-if="plugin.description" class="plugin__desc">
{{ pluginDescription(t, plugin.id, plugin.description) }}
{{ pluginDescription(t, plugin) }}
</p>
<AbAlert
v-if="plugin.error && plugin.error !== 'not_enabled'"
@@ -278,7 +280,7 @@ onMounted(load);
<PluginSchemaForm
v-model="drafts[plugin.id]"
:fields="
localizeFields(t, plugin.id, schemaFields(plugin.config_schema))
localizeFields(t, plugin, schemaFields(plugin.config_schema))
"
/>
<div class="plugin__save">

View File

@@ -0,0 +1,23 @@
import { refreshPluginProviders } from '@/hooks/usePluginProviders';
import { refreshPluginUi } from '@/hooks/usePluginUi';
/** 插件集合每变化一次加一;被 KeepAlive 缓存的页面再次进入时据此判断是否重新加载 */
export const pluginSetVersion = ref(0);
/**
* 插件安装、卸载、启停或改配置后调用:同步 config store 的 plugins 段(只刷新
* 插件卡片保存的字段,保留未保存的下载器实例与 slots 修改)、下载器/通知渠道
* 候选与插件前端挂载点。
*/
export async function refreshPluginState() {
pluginSetVersion.value++;
await Promise.all([
useConfigStore().refreshGroup('plugins', [
'allow_unsigned',
'enabled',
'options',
]),
refreshPluginProviders(),
refreshPluginUi(),
]);
}

View File

@@ -4,8 +4,7 @@ import MarketConfirm from '@/components/market/market-confirm.vue';
import type { ApiError } from '#/api';
import type { CatalogEntry, PluginInfo } from '#/plugins';
import { apiPlugins } from '@/api/plugins';
import { refreshPluginProviders } from '@/hooks/usePluginProviders';
import { refreshPluginUi } from '@/hooks/usePluginUi';
import { pluginSetVersion, refreshPluginState } from '@/hooks/usePluginRefresh';
import { idLabel } from '@/utils/id-label';
import { renderMarkdown } from '@/utils/markdown';
import {
@@ -31,7 +30,6 @@ const message = useMessage();
const route = useRoute();
const router = useRouter();
const { isMobile } = useBreakpointQuery();
const { refreshGroup } = useConfigStore();
// 目录:成功获取后写入 localStorage;目录不可达时显示上一次的目录(stale),
// 此时安装与更新不可用。error 为 null 表示最近一次获取成功,'' 表示失败但后端没给原因。
@@ -74,21 +72,12 @@ async function loadInstalled() {
onMounted(() => Promise.all([loadCatalog(), loadInstalled()]));
// 页面被 KeepAlive 缓存;再次进入时按本机插件同步安装状态(设置页可能卸载过),不重新下载目录
let activated = false;
onActivated(async () => {
if (!activated) {
activated = true;
return;
}
await loadInstalled();
if (!catalog.value) return;
catalog.value = catalog.value.map((entry) => ({
...entry,
installed_version:
installed.value.find((p) => p.id === entry.id && p.source === 'catalog')
?.version ?? null,
}));
// 页面被 KeepAlive 缓存:其它页面(如设置)改过插件后,再次进入时重新获取目录与安装状态
let seenVersion = pluginSetVersion.value;
onActivated(() => {
if (seenVersion === pluginSetVersion.value) return;
seenVersion = pluginSetVersion.value;
return Promise.all([loadCatalog(), loadInstalled()]);
});
// 相对时间每分钟刷新一次
@@ -156,7 +145,10 @@ const selected = computed(() => {
const showDetail = computed(() => isMobile.value && Boolean(routeId.value));
const confirming = ref(false);
watch(routeId, () => (confirming.value = false));
watch(
() => selected.value?.id,
() => (confirming.value = false)
);
function back() {
if ((window.history.state as { back?: unknown } | null)?.back) router.back();
@@ -218,15 +210,11 @@ async function install(entry: CatalogEntry) {
? t('market.update_success', { name, version: entry.version })
: t('market.install_success', { name })
);
entry.installed_version = entry.version;
confirming.value = false;
busy.value = false;
// 与设置页插件卡片相同:同步 config store 的 plugins 段、Provider 候选与前端挂载点
await Promise.all([
loadCatalog(),
refreshGroup('plugins', ['allow_unsigned', 'enabled', 'options']),
refreshPluginProviders(),
refreshPluginUi(),
]);
await refreshPluginState();
seenVersion = pluginSetVersion.value;
}
function pointLabel(id: string) {

View File

@@ -165,7 +165,7 @@ describe('market page', () => {
expect(w.findAll('.row-link')).toHaveLength(2);
});
it('should install after the confirm step and refresh catalog, config and plugin UI', async () => {
it('should mark the plugin installed without a catalog refetch when the confirmed install succeeds', async () => {
api.catalog.mockResolvedValue([entry()]);
api.install.mockResolvedValue({ allow_unsigned: false, plugins: [] });
const w = await mountPage();
@@ -178,7 +178,8 @@ describe('market page', () => {
await flushPromises();
expect(api.install).toHaveBeenCalledWith('bili');
expect(api.catalog).toHaveBeenCalledTimes(2);
expect(api.catalog).toHaveBeenCalledTimes(1);
expect(w.text()).toContain('market.state.installed');
expect(refreshGroup).toHaveBeenCalledWith(
'plugins',
expect.arrayContaining(['enabled'])

View File

@@ -22,9 +22,19 @@ describe('renderMarkdown', () => {
'[docs](https://a.io/x_y_z)',
'<p><a href="https://a.io/x_y_z" target="_blank" rel="noopener noreferrer">docs</a></p>',
],
])('should render %j', (source, html) => {
expect(renderMarkdown(source)).toBe(html);
});
[
'[`sdk`](https://a.io)',
'<p><a href="https://a.io" target="_blank" rel="noopener noreferrer"><code>sdk</code></a></p>',
],
['Released in\n2024. It works', '<p>Released in 2024. It works</p>'],
['* * *', '<hr>'],
['# C#', '<h3>C#</h3>'],
])(
'should render %j as the expected HTML when it is the source',
(source, html) => {
expect(renderMarkdown(source)).toBe(html);
}
);
it.each([
[
@@ -40,7 +50,7 @@ describe('renderMarkdown', () => {
['[x](data:text/html,hi)', '<p>x</p>'],
['![pic](https://a.io/p.png)', '<p>pic</p>'],
['# <iframe src=x>', '<h3>&lt;iframe src=x&gt;</h3>'],
])('should neutralise %j', (source, html) => {
])('should neutralise the markup when the source is %j', (source, html) => {
expect(renderMarkdown(source)).toBe(html);
});

View File

@@ -56,14 +56,19 @@ describe('categoriesOf', () => {
describe('entryState', () => {
it.each([
[null, 'available'],
['0.2.0', 'installed'],
['0.1.0', 'update'],
] as const)('should be %s → %s', (installed, state) => {
expect(entryState(catalogEntry({ installed_version: installed }))).toBe(
state
);
});
['available', null],
['installed', '0.2.0'],
['update', '0.1.0'],
['update', '0.2.0-beta.1'],
['installed', '0.3.0'],
] as const)(
'should give state %s when the installed version is %s',
(state, installed) => {
expect(entryState(catalogEntry({ installed_version: installed }))).toBe(
state
);
}
);
});
describe('filterEntries', () => {
@@ -128,7 +133,7 @@ describe('filterEntries', () => {
});
describe('permissionDiff', () => {
it('should split kept, added and removed permissions', () => {
it('should split kept, added and removed permissions when an update changes them', () => {
expect(
permissionDiff(['filesystem', 'fs.write'], ['filesystem', 'network'])
).toEqual({

View File

@@ -38,10 +38,15 @@ function inline(raw: string): string {
: text
);
html = emphasis(html);
return html.replace(/\uE000(\d+)\uE000/g, (_, i: string) => tokens[+i]);
// 链接文字里可能还有行内代码的占位符,逐层还原
const held = /\uE000(\d+)\uE000/g;
while (held.test(html))
html = html.replace(held, (_, i: string) => tokens[+i]);
return html;
}
const LIST_ITEM = /^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/;
const RULE = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
export function renderMarkdown(source: string): string {
const lines = source
@@ -77,7 +82,13 @@ export function renderMarkdown(source: string): string {
code = [];
continue;
}
const heading = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
if (RULE.test(line)) {
flush();
out.push('<hr>');
continue;
}
// 结尾的 # 只有与正文隔着空格时才是闭合符号(如 “C#” 保留)
const heading = /^\s*(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/.exec(line);
if (heading) {
flush();
// README 嵌在详情栏里,标题从 h3 起,不与页面标题争层级
@@ -86,7 +97,11 @@ export function renderMarkdown(source: string): string {
continue;
}
const item = LIST_ITEM.exec(line);
if (item) {
// 有序列表只有从 1 开始才能打断段落,否则 “2024. 起” 这样的行会被当成列表
if (
item &&
!(paragraph.length && item[2] !== undefined && item[2] !== '1')
) {
const ordered = item[2] !== undefined;
if (paragraph.length || (list && list.ordered !== ordered)) flush();
list ??= { ordered, items: [] };

View File

@@ -47,9 +47,22 @@ export function categoriesOf(entry: CatalogEntry): MarketCategory[] {
return MARKET_CATEGORIES.filter((c) => found.has(c));
}
// 按数字比较 x.y.z;数字部分相同时,带预发布后缀(如 -beta.1)的版本较旧
function newerThan(a: string, b: string): boolean {
const [ra, pa] = a.split(/-(.*)/);
const [rb, pb] = b.split(/-(.*)/);
const order = ra.localeCompare(rb, undefined, { numeric: true });
if (order) return order > 0;
if (!pa || !pb) return !pa && Boolean(pb);
return pa.localeCompare(pb, undefined, { numeric: true }) > 0;
}
export function entryState(entry: CatalogEntry): MarketState {
if (!entry.installed_version) return 'available';
return entry.installed_version === entry.version ? 'installed' : 'update';
// 本机版本比目录新(目录回退过)时不提示更新,避免“更新”成旧版本
return newerThan(entry.version, entry.installed_version)
? 'update'
: 'installed';
}
export interface MarketFilter {

View File

@@ -1,39 +1,56 @@
import { hasKey } from './id-label';
import type { SchemaField } from './plugin-schema';
import type { PluginInfo } from '#/plugins';
type Translate = (key: string) => string;
type Plugin = Pick<PluginInfo, 'id' | 'source'>;
/**
* 内置插件的名称、描述与配置字段文案:清单和 config_model 里只有中文,前端在
* `builtin_plugins.<id>` 下为它们各配一份翻译。没有收录的插件(第三方)原样显示
* 后端给的文字。
*/
function root(pluginId: string): string {
return `builtin_plugins.${pluginId.replaceAll('-', '_')}`;
function root(plugin: Plugin): string | null {
// 按 id 查找会让同 id 的第三方插件借用内置插件的文案
return plugin.source === 'builtin'
? `builtin_plugins.${plugin.id.replaceAll('-', '_')}`
: null;
}
function pick(t: Translate, key: string, fallback: string): string {
return hasKey(key) ? t(key) : fallback;
function pick(t: Translate, key: string | null, fallback: string): string {
return key !== null && hasKey(key) ? t(key) : fallback;
}
export function pluginName(t: Translate, id: string, fallback: string): string {
return pick(t, `${root(id)}.name`, fallback);
export function pluginName(
t: Translate,
plugin: Plugin & { name: string }
): string {
const base = root(plugin);
return pick(t, base && `${base}.name`, plugin.name);
}
export function pluginDescription(
t: Translate,
id: string,
fallback: string
plugin: Plugin & { description: string }
): string {
return pick(t, `${root(id)}.description`, fallback);
const base = root(plugin);
return pick(t, base && `${base}.description`, plugin.description);
}
/** 字段文案:`fields.<key>.title / description`,对象数组的子字段嵌套在父字段下 */
export function localizeFields(
t: Translate,
id: string,
fields: SchemaField[],
parent = `${root(id)}.fields`
plugin: Plugin,
fields: SchemaField[]
): SchemaField[] {
const base = root(plugin);
return base ? localizeUnder(t, `${base}.fields`, fields) : fields;
}
function localizeUnder(
t: Translate,
parent: string,
fields: SchemaField[]
): SchemaField[] {
return fields.map((field) => {
const base = `${parent}.${field.key}`;
@@ -41,7 +58,7 @@ export function localizeFields(
...field,
label: pick(t, `${base}.title`, field.label),
description: pick(t, `${base}.description`, field.description),
itemFields: localizeFields(t, id, field.itemFields, base),
itemFields: localizeUnder(t, base, field.itemFields),
};
});
}

View File

@@ -96,12 +96,14 @@ declare global {
const pluginDescription: typeof import('../../src/utils/plugin-text')['pluginDescription']
const pluginName: typeof import('../../src/utils/plugin-text')['pluginName']
const pluginReason: typeof import('../../src/utils/plugin-text')['pluginReason']
const pluginSetVersion: typeof import('../../src/hooks/usePluginRefresh')['pluginSetVersion']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readCachedCatalog: typeof import('../../src/utils/plugin-market')['readCachedCatalog']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refreshPluginProviders: typeof import('../../src/hooks/usePluginProviders')['refreshPluginProviders']
const refreshPluginState: typeof import('../../src/hooks/usePluginRefresh')['refreshPluginState']
const refreshPluginUi: typeof import('../../src/hooks/usePluginUi')['refreshPluginUi']
const relativeTime: typeof import('../../src/utils/relative-time')['relativeTime']
const renderMarkdown: typeof import('../../src/utils/markdown')['renderMarkdown']