fix(webui): 4.0 界面审查第二轮修复

- 通知渠道列表测试:已保存且未改动的行按下标测试后端保存的配置(掩码密钥
  不再导致失败,插件渠道的自带字段得以保留),未保存的行按当前配置测试;
  后端下标改为对应完整渠道列表(含停用项)
- 插件配置表单:草稿深拷贝 options,编辑嵌套行后保存按钮可用
- 插件配置表单:数字字段清空后保持空白,Optional 字段清空为 null
- 标签输入:失焦(focusout)时添加未确认的输入;输入法组字时的回车不添加标签
- 下载器:切回已保存的类型时恢复保存的配置,不再清空凭据
- 插件挂载点加载失败时改为刷新页面,提示文案同步
- 移动端导航:同一插件只显示一个入口,导航项不收缩、可横向滚动
- 插件页面无匹配挂载点时标题回退为插件 id
This commit is contained in:
Estrella Pan
2026-10-09 12:09:07 +02:00
parent 5e15c5cf17
commit ee3ee0a6ab
25 changed files with 287 additions and 87 deletions

View File

@@ -63,15 +63,7 @@ async def test_provider(request: TestProviderRequest):
in the current configuration.
"""
try:
manager = NotificationManager()
if request.provider_index >= len(manager):
return TestResponse(
success=False,
message=f"Invalid provider index: {request.provider_index}",
message_zh=f"无效的提供者索引: {request.provider_index}",
message_en=f"Invalid provider index: {request.provider_index}",
)
manager = NotificationManager(load_providers=False)
success, message = await manager.test_provider(request.provider_index)
return TestResponse(
success=success,

View File

@@ -148,23 +148,16 @@ class NotificationManager:
await self._broadcast("system event", send_one)
async def test_provider(self, index: int) -> tuple[bool, str]:
"""Test a specific provider by index.
"""按下标测试已保存的渠道。
Args:
index: The index of the provider in the providers list.
Returns:
A tuple of (success, message).
下标对应 ``settings.notification.providers`` 的完整列表(含停用项),
与前端列表一致;``self.providers`` 只含已启用且构造成功的渠道,不能
按它取下标。使用保存的配置,密钥是真实值而非前端拿到的掩码。
"""
if index < 0 or index >= len(self.providers):
providers = settings.notification.providers
if index < 0 or index >= len(providers):
return False, f"Invalid provider index: {index}"
provider = self.providers[index]
try:
async with provider:
return await provider.test()
except Exception as e:
return False, f"Test failed: {e}"
return await self.test_provider_config(providers[index])
@staticmethod
async def test_provider_config(config: "ProviderConfig") -> tuple[bool, str]:

View File

@@ -165,23 +165,24 @@ class TestNotificationManager:
for provider in manager.providers:
provider.send.assert_called_once_with(notify) # type: ignore[attr-defined]
async def test_test_provider(self, mock_settings):
"""Manager can test a specific provider."""
config = ProviderConfig(
async def test_test_provider_index_counts_disabled_rows(self, mock_settings):
"""下标对应已保存的完整渠道列表(含停用项),用保存的配置测试。"""
disabled = ProviderConfig(type="bark", enabled=False, device_key="k")
target = ProviderConfig(
type="telegram", enabled=True, token="test", chat_id="123"
)
mock_settings.notification.providers = [config]
mock_settings.notification.providers = [disabled, target]
manager = NotificationManager()
# Mock the provider's test method
manager.providers[0].test = AsyncMock(return_value=(True, "Test successful"))
manager.providers[0].__aenter__ = AsyncMock(return_value=manager.providers[0])
manager.providers[0].__aexit__ = AsyncMock(return_value=None)
success, message = await manager.test_provider(0)
with patch.object(
NotificationManager,
"test_provider_config",
AsyncMock(return_value=(True, "Test successful")),
) as tested:
success, message = await manager.test_provider(1)
assert success is True
assert message == "Test successful"
tested.assert_awaited_once_with(target)
async def test_test_provider_invalid_index(self, mock_settings):
"""Manager handles invalid provider index."""

View File

@@ -166,8 +166,12 @@ describe('plugin-slot', () => {
expect(wrapper.findComponent(AbSkeleton).exists()).toBe(false);
});
it('should show the reason and a next step, and retry on demand', async () => {
// 浏览器缓存失败的模块,原地重新导入无法恢复,只能刷新页面
it('should show the reason and a next step, and reload the page on demand', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const reload = vi
.spyOn(window.location, 'reload')
.mockImplementation(() => {});
loadPluginElement.mockRejectedValueOnce(new Error('bundle 404'));
const wrapper = mountSlot(makeUi('broken'));
await flushPromises();
@@ -179,7 +183,7 @@ describe('plugin-slot', () => {
await alert.findComponent(AbButton).trigger('click');
await flushPromises();
expect(wrapper.findComponent(AbAlert).exists()).toBe(false);
expect(shadowText(wrapper)).toContain('plugin content');
expect(reload).toHaveBeenCalledTimes(1);
expect(loadPluginElement).toHaveBeenCalledTimes(1);
});
});

View File

@@ -27,4 +27,30 @@ describe('ab-tags-input', () => {
await wrapper.find('.ab-tag-close').trigger('click');
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['b']]);
});
// blur 不冒泡,组件根节点上的监听收不到;focusout 才会从 input 冒泡上来
it('should add the typed tag when the input loses focus', async () => {
const wrapper = mount(AbTagsInput, {
props: { modelValue: [] },
global: { mocks: { $t: (k: string) => k } },
});
const input = wrapper.find('input');
await input.setValue('c');
await input.trigger('focusout');
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['c']]);
});
it.each([{ isComposing: true }, { keyCode: 229 }])(
'should ignore Enter during IME composition (%j)',
async (init) => {
const wrapper = mount(AbTagsInput, {
props: { modelValue: [] },
global: { mocks: { $t: (k: string) => k } },
});
const input = wrapper.find('input');
await input.setValue('ni');
await input.trigger('keydown', { key: 'Enter', ...init });
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
}
);
});

View File

@@ -13,6 +13,12 @@ function add() {
model.value = [...model.value, value];
draft.value = '';
}
// 输入法组字时的回车用于确认候选词,不添加标签
function onEnter(event: KeyboardEvent) {
if (event.isComposing || event.keyCode === 229) return;
add();
}
</script>
<template>
@@ -30,8 +36,8 @@ function add() {
<AbInput
v-model="draft"
:aria-label="ariaLabel"
@keydown.enter.prevent="add"
@blur="add"
@keydown.enter.prevent="onEnter"
@focusout="add"
/>
</div>
</template>

View File

@@ -15,7 +15,7 @@ vi.mock('@/hooks/useDarkMode', async () => {
vi.mock('vue-inline-svg', () => ({ default: { template: '<i />' } }));
vi.mock('@/hooks/usePluginUi', () => ({
slotTitle: (ui: { title: Record<string, string> }) => ui.title['en-US'],
useUiSlots: () =>
usePluginPages: () =>
ref([
{
plugin_id: 'demo',

View File

@@ -10,7 +10,7 @@ import {
Sun,
} from '@icon-park/vue-next';
import InlineSvg from 'vue-inline-svg';
import { slotTitle, useUiSlots } from '@/hooks/usePluginUi';
import { slotTitle, usePluginPages } from '@/hooks/usePluginUi';
const { t, lang } = useMyI18n();
const route = useRoute();
@@ -56,7 +56,7 @@ const navItems = [
];
// 插件经 page 挂载点提供的页面,排在设置之前;导航栏可横向滚动
const pageSlots = useUiSlots('page');
const pageSlots = usePluginPages();
const visibleItems = computed(() => {
const pluginItems = pageSlots.value.map((ui, index) => ({
id: 100 + index,
@@ -121,13 +121,14 @@ const visibleItems = computed(() => {
}
&__item {
flex: 1;
// 不收缩:项多时导航栏横向滚动,保持 44px 以上的触控区域
flex: 1 0 auto;
min-width: 56px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 0;
height: 56px;
padding: 6px 4px;
cursor: pointer;
@@ -172,7 +173,7 @@ const visibleItems = computed(() => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
max-width: 6em;
line-height: 1.2;
}
}

View File

@@ -13,7 +13,7 @@ import {
Sun,
} from '@icon-park/vue-next';
import InlineSvg from 'vue-inline-svg';
import { slotTitle, useUiSlots } from '@/hooks/usePluginUi';
import { slotTitle, usePluginPages } from '@/hooks/usePluginUi';
const props = withDefaults(
defineProps<{
@@ -94,7 +94,7 @@ const items = [
];
// 插件经 page 挂载点提供的页面,排在设置之前
const pageSlots = useUiSlots('page');
const pageSlots = usePluginPages();
const navItems = computed(() => {
const pluginItems = pageSlots.value.map((ui, index) => ({
id: 100 + index,

View File

@@ -69,6 +69,12 @@ async function mount() {
}
}
// 浏览器会缓存求值失败的模块(插件加载器只丢弃失败的 Promise),
// 原地重新导入无法恢复,只能刷新页面
function reload() {
location.reload();
}
function unmount() {
generation += 1;
teardown?.();
@@ -95,7 +101,9 @@ watch(
<span v-if="reason" class="plugin-slot__id">{{ reason }}</span>
{{ $t('plugin.load_failed_hint') }}
<template #action>
<ab-button size="sm" @click="mount">{{ $t('plugin.retry') }}</ab-button>
<ab-button size="sm" @click="reload">{{
$t('plugin.reload')
}}</ab-button>
</template>
</ab-alert>
<div v-show="!failed" ref="container" class="plugin-slot__body"></div>

View File

@@ -38,9 +38,11 @@ function instance(id: string) {
}
const state = ref({} as Plugins);
// 服务端最近一次加载/保存的快照
const saved = { plugins: {} as Plugins };
vi.mock('@/store/config', () => ({
useConfigStore: () => ({ getSettingGroup: () => state }),
useConfigStore: () => ({ getSettingGroup: () => state, savedConfig: saved }),
}));
const AbSettingStub = defineComponent({
@@ -100,6 +102,7 @@ describe('config-download', () => {
slots: { downloader: 'default' },
instances: [instance('default'), instance('nas')],
} as unknown as Plugins;
saved.plugins = JSON.parse(JSON.stringify(state.value));
});
it('should list every downloader instance and mark the default', () => {
@@ -203,6 +206,28 @@ describe('config-download', () => {
expect(state.value.instances[0].options.host).toBe('');
});
// 切走再切回已保存的类型时恢复保存的配置(含掩码密码,保存时由后端还原)
it('should restore the saved options when switching back to the saved provider', async () => {
saved.plugins.instances[0].options.password = '********';
state.value.instances[0].options.password = '********';
const wrapper = mountIt();
const type = wrapper
.findAllComponents(AbSettingStub)
.find(
(s) =>
(s.props('label') as () => string)() === 'config.downloader_set.type'
);
await type?.vm.$emit('update:data', 'aria2');
await type?.vm.$emit('update:data', 'qbittorrent');
expect(state.value.instances[0].provider).toBe('qbittorrent');
expect(state.value.instances[0].options).toEqual(
saved.plugins.instances[0].options
);
expect(state.value.instances[0].options).not.toBe(
saved.plugins.instances[0].options
);
});
it('should hide the username field for aria2', async () => {
state.value.instances[0].provider = 'aria2';
const wrapper = mountIt();

View File

@@ -20,10 +20,14 @@ vi.mock('@/hooks/usePluginProviders', () => ({
}));
const group = ref({
enable: true,
providers: [{ type: 'telegram', enabled: true, token: 'tk' }],
providers: [
{ type: 'telegram', enabled: true, token: '********' },
{ type: 'ntfy', enabled: true, topic: '********' },
],
});
const saved = { notification: { providers: [] as unknown[] } };
vi.mock('@/store/config', () => ({
useConfigStore: () => ({ getSettingGroup: () => group }),
useConfigStore: () => ({ getSettingGroup: () => group, savedConfig: saved }),
}));
const api = vi.mocked(apiNotification);
@@ -61,9 +65,33 @@ describe('config-notification', () => {
beforeEach(() => {
vi.clearAllMocks();
api.testProviderConfig.mockResolvedValue(ok as any);
api.testProvider.mockResolvedValue(ok as any);
saved.notification.providers = JSON.parse(
JSON.stringify(group.value.providers)
);
});
it('should test the in-memory config and name the provider when testing from the list', async () => {
// /config/get 返回的密钥是掩码,列表测试须按下标走后端保存的配置
it.each([
[0, 'Telegram: ok'],
[1, 'config.notification_set.plugin_label:ntfy: ok'],
])(
'should test saved row %i by index when testing from the list',
async (index, text) => {
const w = mountPage();
await w
.findAll('[aria-label="config.notification_set.test"]')
[index].trigger('click');
await flushPromises();
expect(api.testProviderConfig).not.toHaveBeenCalled();
expect(api.testProvider).toHaveBeenCalledWith({ provider_index: index });
expect(w.find('.test-result').text()).toBe(text);
}
);
// 未保存的行(新增或删除后下标错位)在后端没有对应项,按当前配置测试
it('should test an unsaved row with its in-memory config', async () => {
saved.notification.providers = [];
const w = mountPage();
await w
.find('[aria-label="config.notification_set.test"]')
@@ -71,9 +99,8 @@ describe('config-notification', () => {
await flushPromises();
expect(api.testProvider).not.toHaveBeenCalled();
expect(api.testProviderConfig).toHaveBeenCalledWith(
expect.objectContaining({ type: 'telegram', token: 'tk' })
expect.objectContaining({ type: 'telegram' })
);
expect(w.find('.test-result').text()).toBe('Telegram: ok');
});
it('should keep dialog test results out of the list and clear them on close', async () => {

View File

@@ -41,17 +41,29 @@ describe('plugin-schema-form', () => {
});
describe('plugin-schema-form number clear', () => {
// 清空后保持空白以便重新输入,不立刻填回默认值;Optional 字段清空为 null
it.each([
[{ type: 'integer' as const, default: 5 }, 5],
[{ type: 'integer' as const }, undefined],
])('should reset %j to its default when cleared', async (prop, expected) => {
[{ type: 'integer' as const, default: 5 }, false, undefined],
[{ type: 'integer' as const }, false, undefined],
[
{
anyOf: [{ type: 'integer' as const }, { type: 'null' as const }],
default: 5,
},
true,
null,
],
])('should keep %j empty when cleared', async (prop, hasKey, expected) => {
const fields = schemaFields({ properties: { n: prop } });
const model: Record<string, unknown> = { n: 9 };
const wrapper = mount(PluginSchemaForm, {
props: { fields, modelValue: model },
});
await wrapper.find('input').setValue('');
const input = wrapper.find('input');
await input.setValue('');
expect('n' in model).toBe(hasKey);
expect(model.n).toBe(expected);
expect(input.element.value).toBe('');
});
});

View File

@@ -7,7 +7,8 @@ import { usePluginProviders } from '@/hooks/usePluginProviders';
const { t } = useMyI18n();
const { confirm } = useConfirm();
const { getSettingGroup } = useConfigStore();
const configStore = useConfigStore();
const { getSettingGroup } = configStore;
const plugins = getSettingGroup('plugins');
const downloaders = computed(() =>
@@ -34,7 +35,7 @@ const idError = computed(() => {
return idTaken.value ? t('config.downloader_set.id_taken') : '';
});
// 空白 options;切换下载器类型时重置,避免旧类型的凭据带入新类型
// 空白 options;切换到新类型时重置,避免旧类型的凭据带入新类型
function defaultOptions() {
return {
host: '',
@@ -45,10 +46,19 @@ function defaultOptions() {
} satisfies DownloaderOptions;
}
// 切回已保存的类型时恢复保存的 options(掩码密码保存时由后端还原),
// 否则切走再切回会把已保存的凭据清空
function setProvider(provider: string) {
if (!downloader.value || downloader.value.provider === provider) return;
downloader.value.provider = provider;
downloader.value.options = defaultOptions();
const current = downloader.value;
if (!current || current.provider === provider) return;
const saved = configStore.savedConfig.plugins.instances.find(
(i) => i.id === current.id
);
current.provider = provider;
current.options =
saved?.provider === provider
? JSON.parse(JSON.stringify(saved.options))
: defaultOptions();
}
function addInstance() {

View File

@@ -8,7 +8,8 @@ import { usePluginProviders } from '@/hooks/usePluginProviders';
const { t, returnUserLangText } = useMyI18n();
const { confirm } = useConfirm();
const { getSettingGroup } = useConfigStore();
const configStore = useConfigStore();
const { getSettingGroup } = configStore;
const notificationRef = getSettingGroup('notification');
@@ -214,10 +215,10 @@ function toggleProvider(index: number) {
}
async function runTest(
config: NotificationProviderConfig
request: () => ReturnType<typeof apiNotification.testProvider>
): Promise<TestResult> {
try {
const response = await apiNotification.testProviderConfig(config as any);
const response = await request();
return {
success: response.data.success,
message: returnUserLangText({
@@ -233,12 +234,20 @@ async function runTest(
}
}
// 列表测试发送当前(含未保存)的配置,而不是后端已保存的第 index 项
// 已保存且未改动的行按下标使用后端保存的配置:前端拿到的密钥是掩码,
// 发回去必然失败;插件渠道的自带字段也只有后端的完整配置里才有。
// 其余行(新增、删除后下标错位、已编辑)在后端没有对应项,按当前配置测试
async function testProvider(index: number) {
testingIndex.value = index;
listResult.value = null;
const provider = providers.value[index];
const result = await runTest(provider);
const saved = configStore.savedConfig.notification.providers?.[index];
const unchanged = JSON.stringify(saved) === JSON.stringify(provider);
const result = await runTest(() =>
unchanged
? apiNotification.testProvider({ provider_index: index })
: apiNotification.testProviderConfig(provider as any)
);
listResult.value = {
...result,
message: `${getProviderLabel(provider.type)}: ${result.message}`,
@@ -249,7 +258,9 @@ async function testProvider(index: number) {
async function testNewProvider() {
testingIndex.value = -999; // Special index for new provider
dialogResult.value = null;
dialogResult.value = await runTest(newProvider.value);
dialogResult.value = await runTest(() =>
apiNotification.testProviderConfig(newProvider.value as any)
);
testingIndex.value = -1;
}

View File

@@ -76,10 +76,10 @@ async function removeRow(field: SchemaField, index: number) {
function setNumber(field: SchemaField, value: string | number) {
const parsed = Number(value);
if (value === '' || Number.isNaN(parsed)) {
// 清空时回到 schema 默认值,没有默认值才删除该键
if (field.default === undefined || field.default === null)
delete model.value[field.key];
else model.value[field.key] = field.default;
// 清空后保持空白便于重新输入:Optional 字段存 null,其余删除该键
// (保存时由后端取默认值)
if (field.nullable) model.value[field.key] = null;
else delete model.value[field.key];
return;
}
model.value[field.key] = field.integer ? Math.trunc(parsed) : parsed;

View File

@@ -5,7 +5,11 @@ import { flushPromises } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { apiPlugins } from '@/api/plugins';
import { refreshPluginUi, usePluginUi } from '@/hooks/usePluginUi';
import {
refreshPluginUi,
usePluginPages,
usePluginUi,
} from '@/hooks/usePluginUi';
vi.mock('@/api/plugins', () => ({
apiPlugins: { ui: vi.fn() },
@@ -25,4 +29,25 @@ describe('usePluginUi', () => {
expect(loadFailed.value).toBe(false);
expect(loaded.value).toBe(true);
});
// 同一插件的多个页面共用 /plugins/<id> 路由,导航里只出现一项(取第一个页面)
it('should list one page entry per plugin', async () => {
const page = (plugin_id: string, element: string) => ({
plugin_id,
slot: 'page',
element,
entry: 'web/index.js',
title: { 'en-US': element },
});
vi.mocked(apiPlugins.ui).mockResolvedValueOnce([
page('a', 'a-one'),
page('a', 'a-two'),
page('b', 'b-one'),
] as never);
await refreshPluginUi();
expect(usePluginPages().value.map((ui) => ui.element)).toEqual([
'a-one',
'b-one',
]);
});
});

View File

@@ -35,6 +35,19 @@ export function useUiSlots(slot: UiSlot) {
return computed(() => all.value.filter((s) => s.slot === slot));
}
/**
* 导航用的插件页面:每个插件一项(取它的第一个 page 挂载点)。同一插件的
* 多个页面共用 /plugins/<id> 路由,逐个列出会出现重复入口。
*/
export function usePluginPages() {
const pages = useUiSlots('page');
return computed(() =>
pages.value.filter(
(ui, i, all) => all.findIndex((s) => s.plugin_id === ui.plugin_id) === i
)
);
}
/** 按当前语言取标题:`zh-CN` → `en-US` → 第一个。 */
export function slotTitle(ui: PluginUiSlot, locale: string): string {
return ui.title[locale] ?? ui.title['en-US'] ?? Object.values(ui.title)[0];

View File

@@ -627,8 +627,9 @@
"load_failed": "Plugin component failed to load.",
"page_missing": "This plugin has no page",
"page_missing_hint": "The plugin is disabled, or it does not declare a page.",
"load_failed_hint": "Retry, or reload the page. If it keeps failing, disable the plugin in Settings > Plugins.",
"load_failed_hint": "Reload the page to try again. If it keeps failing, disable the plugin in Settings > Plugins.",
"retry": "Retry",
"reload": "Reload page",
"load_failed_page": "Could not load plugin pages"
},
"sidebar": {

View File

@@ -627,8 +627,9 @@
"load_failed": "插件组件加载失败。",
"page_missing": "该插件没有页面",
"page_missing_hint": "插件未启用,或没有声明页面。",
"load_failed_hint": "请重试或刷新页面。仍然失败时,请在 设置 > 插件 中停用该插件。",
"load_failed_hint": "请刷新页面后重试。仍然失败时,请在 设置 > 插件 中停用该插件。",
"retry": "重试",
"reload": "刷新页面",
"load_failed_page": "无法加载插件页面"
},
"sidebar": {

View File

@@ -15,9 +15,11 @@ vi.mock('@/hooks/useMyI18n', async () => {
});
const slots = ref<unknown[]>([]);
const loaded = ref(false);
const loadFailed = ref(false);
vi.mock('@/hooks/usePluginUi', () => ({
slotTitle: (ui: { title: Record<string, string> }) => ui.title['en-US'],
usePluginUi: () => ({ slots }),
usePluginUi: () => ({ slots, loaded, loadFailed }),
}));
vi.mock('@/store/bangumi', () => ({
useBangumiStore: () => ({ editRule: ref({}) }),
@@ -37,6 +39,8 @@ function mountIndex() {
describe('index page title', () => {
beforeEach(() => {
setActivePinia(createPinia());
loaded.value = false;
loadFailed.value = false;
});
it('should leave the title empty on a plugin route until the manifest arrives', async () => {
@@ -50,4 +54,19 @@ describe('index page title', () => {
await w.vm.$nextTick();
expect(w.find('h1').text()).toBe('Demo page');
});
// 插件已停用/不存在或清单加载失败时没有匹配的页面,标题回退为插件 id
it.each([
[true, false],
[false, true],
])(
'should fall back to the plugin id when loaded=%s failed=%s and no page matches',
async (isLoaded, failed) => {
slots.value = [];
loaded.value = isLoaded;
loadFailed.value = failed;
const w = mountIndex();
expect(w.find('h1').text()).toBe('demo');
}
);
});

View File

@@ -9,19 +9,18 @@ definePage({
const { editRule } = storeToRefs(useBangumiStore());
// 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin);其它页面沿用路由名
// 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin;清单已到达
// 或加载失败仍无匹配页面时回退为插件 id);其它页面沿用路由名
const route = useRoute();
const { lang } = useMyI18n();
const { slots } = usePluginUi();
const { slots, loaded, loadFailed } = usePluginUi();
const pageTitle = computed(() => {
const name = String(route.name ?? '');
if (name !== 'Plugin') return name;
const ui = slots.value.find(
(s) =>
s.slot === 'page' &&
s.plugin_id === (route.params as Record<string, string>).id
);
return ui ? slotTitle(ui, lang.value === 'zh-CN' ? 'zh-CN' : 'en-US') : '';
const id = (route.params as Record<string, string>).id;
const ui = slots.value.find((s) => s.slot === 'page' && s.plugin_id === id);
if (ui) return slotTitle(ui, lang.value === 'zh-CN' ? 'zh-CN' : 'en-US');
return loaded.value || loadFailed.value ? id : '';
});
const { updateRule, enableRule, archiveRule, unarchiveRule, ruleManage } =
useBangumiStore();

View File

@@ -71,6 +71,7 @@ export const useConfigStore = defineStore('config', () => {
return {
config,
savedConfig,
lastSaveError,
dirtyGroups,
isDirty,

View File

@@ -132,3 +132,22 @@ describe('fillSchemaDefaults', () => {
expect(schema.properties!.tags.default).toEqual([]);
});
});
describe('fillSchemaDefaults deep copy', () => {
// 草稿与已保存的 options 共用嵌套行时,编辑行会同时改到"已保存",脏值检测失效
it('should not share nested rows with the saved options', () => {
const saved = { rows: [{ from: 'a' }] };
const draft = fillSchemaDefaults([], saved);
(draft.rows as { from: string }[])[0].from = 'b';
expect(saved.rows[0].from).toBe('a');
});
});
describe('schemaFields nullable', () => {
it('should mark Optional (anyOf with null) fields as nullable', () => {
const fields = schemaFields(schema);
const nullable = Object.fromEntries(fields.map((f) => [f.key, f.nullable]));
expect(nullable.note).toBe(true);
expect(nullable.limit).toBe(false);
});
});

View File

@@ -24,6 +24,8 @@ export interface SchemaField {
default: unknown;
/** 出现在 schema 的 required 列表中(pydantic 中没有默认值的字段) */
required: boolean;
/** Optional 字段(anyOf 含 null),清空时存为 null */
nullable: boolean;
/** number 字段的取值范围 */
minimum?: number;
maximum?: number;
@@ -88,6 +90,7 @@ function buildFields(
: [],
default: prop.default,
required: required.includes(key),
nullable: Boolean(raw.anyOf?.some((p) => p.type === 'null')),
minimum: prop.minimum,
maximum: prop.maximum,
};
@@ -100,12 +103,15 @@ export function schemaFields(schema: JsonSchema | null): SchemaField[] {
return buildFields(schema.properties, schema.$defs ?? {}, schema.required);
}
/** 未保存过的字段用 schema 默认值填充,供表单初始展示 */
/**
* 未保存过的字段用 schema 默认值填充,供表单初始展示。深拷贝 options:
* 表单会原地修改嵌套行,与已保存的 options 共用对象会让脏值检测失效。
*/
export function fillSchemaDefaults(
fields: SchemaField[],
options: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = { ...options };
const result: Record<string, unknown> = JSON.parse(JSON.stringify(options));
for (const field of fields) {
if (!(field.key in result) && field.default !== undefined) {
result[field.key] = JSON.parse(JSON.stringify(field.default));