mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 05:37:14 +08:00
fix(webui): 处理市场页与内置插件文案的审查意见
- 设置页与市场页通过 pluginSetVersion 在再次进入时互相同步;刷新逻辑合并为 refreshPluginState - 安装成功后本地更新 installed_version,不再重新下载目录 - 选中的插件变化时关闭确认栏,避免装错插件 - 本机版本比目录新时不提示更新 - 内置插件文案只用于 source 为 builtin 的插件 - README:链接中的行内代码、段落中的“2024.”、分隔线、标题中的“C#”
This commit is contained in:
@@ -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">
|
||||
|
||||
23
webui/src/hooks/usePluginRefresh.ts
Normal file
23
webui/src/hooks/usePluginRefresh.ts
Normal 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(),
|
||||
]);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>'],
|
||||
['', '<p>pic</p>'],
|
||||
['# <iframe src=x>', '<h3><iframe src=x></h3>'],
|
||||
])('should neutralise %j', (source, html) => {
|
||||
])('should neutralise the markup when the source is %j', (source, html) => {
|
||||
expect(renderMarkdown(source)).toBe(html);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
2
webui/types/dts/auto-imports.d.ts
vendored
2
webui/types/dts/auto-imports.d.ts
vendored
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user