mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-04 22:55:03 +08:00
fix(webui): 4.0 界面审查第二轮修复
- 通知渠道列表测试:已保存且未改动的行按下标测试后端保存的配置(掩码密钥 不再导致失败,插件渠道的自带字段得以保留),未保存的行按当前配置测试; 后端下标改为对应完整渠道列表(含停用项) - 插件配置表单:草稿深拷贝 options,编辑嵌套行后保存按钮可用 - 插件配置表单:数字字段清空后保持空白,Optional 字段清空为 null - 标签输入:失焦(focusout)时添加未确认的输入;输入法组字时的回车不添加标签 - 下载器:切回已保存的类型时恢复保存的配置,不再清空凭据 - 插件挂载点加载失败时改为刷新页面,提示文案同步 - 移动端导航:同一插件只显示一个入口,导航项不收缩、可横向滚动 - 插件页面无匹配挂载点时标题回退为插件 id
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -627,8 +627,9 @@
|
||||
"load_failed": "插件组件加载失败。",
|
||||
"page_missing": "该插件没有页面",
|
||||
"page_missing_hint": "插件未启用,或没有声明页面。",
|
||||
"load_failed_hint": "请重试或刷新页面。仍然失败时,请在 设置 > 插件 中停用该插件。",
|
||||
"load_failed_hint": "请刷新页面后重试。仍然失败时,请在 设置 > 插件 中停用该插件。",
|
||||
"retry": "重试",
|
||||
"reload": "刷新页面",
|
||||
"load_failed_page": "无法加载插件页面"
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -71,6 +71,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
return {
|
||||
config,
|
||||
savedConfig,
|
||||
lastSaveError,
|
||||
dirtyGroups,
|
||||
isDirty,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user