fix(webui): address code-review findings before beta

- Nest ab-edit-rule's delete confirmation inside the main ab-modal so
  headlessui suspends the outer dialog's Escape/outside-click while the
  inner one is open (clicking the delete-files checkbox no longer
  closes both layers); regression-tested with a nested-modal vitest.
- Restore autocomplete passthrough (ab-input prop + ab-setting wiring)
  so downloader/proxy password fields keep autocomplete=off.
- Guarantee 44px touch targets for ab-button sm on touch; repoint or
  remove the dead .n-button deep selectors in wizard/config/downloader/
  log left over from the sweep.
- a11y labels: modal X and search closes announce 'Close', tag remove
  announces 'Remove' (new common.close/common.remove i18n pairs);
  ab-field renders label[for] only when the control adopts the id,
  span + aria-labelledby otherwise.
- Convert the last NPopconfirms (passkey/search-provider/update-card)
  to useConfirm with danger icon buttons; drop legacy
  .notification-action CSS that fought ab-button variants.
- Fix missing homepage.rule.offset key (→ episode_offset), normalize
  test names to the should-convention, run prettier over the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
Estrella Pan
2026-07-06 12:38:23 +02:00
parent 3756dc7e8a
commit 6c6425d033
52 changed files with 589 additions and 536 deletions

View File

@@ -156,92 +156,86 @@ function subscribe() {
<ab-modal v-model:show="show" :title="$t('topbar.add.title')">
<!-- Step 1: Input RSS -->
<div v-if="step === 'input'" class="form-section">
<!-- RSS Link -->
<div class="form-group">
<label class="form-label">{{
$t('topbar.add.rss_link')
}}</label>
<div class="input-wrapper">
<Link theme="outline" size="16" class="input-icon" />
<input
v-model="rss.url"
type="text"
class="form-input form-input--with-icon"
:placeholder="$t('topbar.add.placeholder_link')"
/>
</div>
</div>
<!-- RSS Link -->
<div class="form-group">
<label class="form-label">{{ $t('topbar.add.rss_link') }}</label>
<div class="input-wrapper">
<Link theme="outline" size="16" class="input-icon" />
<input
v-model="rss.url"
type="text"
class="form-input form-input--with-icon"
:placeholder="$t('topbar.add.placeholder_link')"
/>
</div>
</div>
<!-- Name -->
<div class="form-group">
<label class="form-label">{{ $t('topbar.add.name') }}</label>
<input
v-model="rss.name"
type="text"
class="form-input"
:placeholder="$t('topbar.add.placeholder_name')"
/>
</div>
<!-- Name -->
<div class="form-group">
<label class="form-label">{{ $t('topbar.add.name') }}</label>
<input
v-model="rss.name"
type="text"
class="form-input"
:placeholder="$t('topbar.add.placeholder_name')"
/>
</div>
<!-- Options row -->
<div class="options-row">
<!-- Aggregate Switch -->
<div class="option-item">
<label class="option-label">{{
$t('topbar.add.aggregate')
}}</label>
<NSwitch v-model:value="rss.aggregate" />
</div>
<!-- Options row -->
<div class="options-row">
<!-- Aggregate Switch -->
<div class="option-item">
<label class="option-label">{{ $t('topbar.add.aggregate') }}</label>
<NSwitch v-model:value="rss.aggregate" />
</div>
<!-- Parser Select -->
<div class="option-item">
<label class="option-label">{{
$t('topbar.add.parser')
}}</label>
<NSelect
v-model:value="rss.parser"
:options="parserTypes.map((p) => ({ label: p, value: p }))"
class="parser-select"
/>
</div>
</div>
<!-- Parser Select -->
<div class="option-item">
<label class="option-label">{{ $t('topbar.add.parser') }}</label>
<NSelect
v-model:value="rss.parser"
:options="parserTypes.map((p) => ({ label: p, value: p }))"
class="parser-select"
/>
</div>
</div>
</div>
<!-- Step 2: Confirm -->
<div v-else class="confirm-section">
<bangumi-preview v-model:rule="rule" :poster-src="posterSrc" />
<bangumi-preview v-model:rule="rule" :poster-src="posterSrc" />
<bangumi-info-tags :tags="infoTags" />
<bangumi-info-tags :tags="infoTags" />
<bangumi-rss-link-row
:link="rssLink"
:copied="copied"
@copy="copyRssLink(rssLink)"
/>
<bangumi-rss-link-row
:link="rssLink"
:copied="copied"
@copy="copyRssLink(rssLink)"
/>
<!-- Advanced settings -->
<advanced-section v-model:open="showAdvanced">
<bangumi-filter-field v-model="rule.filter" />
<!-- Advanced settings -->
<advanced-section v-model:open="showAdvanced">
<bangumi-filter-field v-model="rule.filter" />
<bangumi-offset-field
v-model="rule.episode_offset"
:label="$t('homepage.rule.offset')"
>
<template #action>
<button
class="detect-btn"
:disabled="offsetLoading || !rule.id"
@click="autoDetectOffset"
>
<NSpin v-if="offsetLoading" :size="14" />
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
</button>
</template>
</bangumi-offset-field>
<div v-if="offsetReason" class="offset-reason">
{{ offsetReason }}
</div>
</advanced-section>
<bangumi-offset-field
v-model="rule.episode_offset"
:label="$t('homepage.rule.episode_offset')"
>
<template #action>
<button
class="detect-btn"
:disabled="offsetLoading || !rule.id"
@click="autoDetectOffset"
>
<NSpin v-if="offsetLoading" :size="14" />
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
</button>
</template>
</bangumi-offset-field>
<div v-if="offsetReason" class="offset-reason">
{{ offsetReason }}
</div>
</advanced-section>
</div>
<template #footer>
@@ -448,6 +442,5 @@ function subscribe() {
justify-content: space-between;
width: 100%;
}
}
</style>

View File

@@ -7,7 +7,11 @@ const { user, update } = useAuth();
</script>
<template>
<ab-modal v-model:show="show" size="sm" :title="$t('topbar.profile.pop_title')">
<ab-modal
v-model:show="show"
size="sm"
:title="$t('topbar.profile.pop_title')"
>
<div space-y-16>
<ab-field :label="$t('topbar.profile.username')">
<ab-input

View File

@@ -47,7 +47,7 @@ withDefaults(
border-bottom: 1px solid var(--color-border);
user-select: none;
transition: color var(--transition-normal),
border-color var(--transition-normal);
border-color var(--transition-normal);
}
.container-title {
@@ -63,7 +63,7 @@ withDefaults(
color: var(--color-text);
font-size: 14px;
transition: background-color var(--transition-normal),
color var(--transition-normal);
color var(--transition-normal);
&--flush {
padding: 0;

View File

@@ -214,137 +214,161 @@ function emitUnarchive() {
:title="$t('homepage.rule.edit_rule')"
@close="close"
>
<!-- Needs Review Warning Banner -->
<div v-if="localRule.needs_review" class="review-warning">
<div class="review-warning-main">
<span class="review-warning-emoji"></span>
<div class="review-warning-content">
<div class="review-warning-title">
{{ $t('offset.needs_review') }}
</div>
<div
v-if="localRule.needs_review_reason"
class="review-warning-reason"
>
{{ localRule.needs_review_reason }}
</div>
</div>
</div>
<div class="review-warning-actions">
<button
class="detect-btn"
:disabled="offsetLoading"
@click="autoDetectOffset"
>
<NSpin v-if="offsetLoading" :size="12" />
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
</button>
<button
class="dismiss-btn"
:disabled="dismissingReview"
@click="dismissReview"
>
<NSpin v-if="dismissingReview" :size="12" />
<span v-else>{{ $t('offset.dismiss') }}</span>
</button>
</div>
<!-- Needs Review Warning Banner -->
<div v-if="localRule.needs_review" class="review-warning">
<div class="review-warning-main">
<span class="review-warning-emoji"></span>
<div class="review-warning-content">
<div class="review-warning-title">
{{ $t('offset.needs_review') }}
</div>
<!-- Content -->
<div class="edit-content">
<bangumi-preview v-model:rule="localRule" :poster-src="posterSrc" />
<bangumi-info-tags :tags="infoTags" />
<bangumi-rss-link-row
:link="rssLink"
:copied="copied"
@copy="copyRssLink(rssLink)"
/>
<!-- Advanced settings -->
<advanced-section v-model:open="showAdvanced">
<bangumi-filter-field v-model="localRule.filter" />
<bangumi-offset-field
v-model="localRule.season_offset"
:label="$t('homepage.rule.season_offset')"
/>
<bangumi-offset-field
v-model="localRule.episode_offset"
:label="$t('homepage.rule.episode_offset')"
/>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.air_weekday')
}}</label>
<NSelect
:value="localRule.air_weekday ?? null"
:options="weekdayOptions"
clearable
size="small"
:placeholder="$t('calendar.unknown')"
class="weekday-select"
@update:value="onWeekdayChange"
/>
</div>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.episode_type')
}}</label>
<NSelect
v-model:value="localRule.episode_type"
:options="episodeTypeOptions"
size="small"
class="weekday-select"
/>
</div>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.preferred_group')
}}</label>
<ab-input
:model-value="localRule.preferred_group ?? ''"
type="text"
class="preferred-input"
placeholder="ANi"
@update:model-value="localRule.preferred_group = String($event)"
/>
</div>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.preferred_resolution')
}}</label>
<NSelect
v-model:value="localRule.preferred_resolution"
:options="resolutionOptions"
clearable
filterable
tag
size="small"
:placeholder="$t('homepage.rule.auto_detect')"
class="weekday-select"
/>
</div>
<p class="preferred-hint">
{{ $t('homepage.rule.preferred_hint') }}
</p>
</advanced-section>
<div
v-if="localRule.needs_review_reason"
class="review-warning-reason"
>
{{ localRule.needs_review_reason }}
</div>
</div>
</div>
<div class="review-warning-actions">
<button
class="detect-btn"
:disabled="offsetLoading"
@click="autoDetectOffset"
>
<NSpin v-if="offsetLoading" :size="12" />
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
</button>
<button
class="dismiss-btn"
:disabled="dismissingReview"
@click="dismissReview"
>
<NSpin v-if="dismissingReview" :size="12" />
<span v-else>{{ $t('offset.dismiss') }}</span>
</button>
</div>
</div>
<!-- Content -->
<div class="edit-content">
<bangumi-preview v-model:rule="localRule" :poster-src="posterSrc" />
<bangumi-info-tags :tags="infoTags" />
<bangumi-rss-link-row
:link="rssLink"
:copied="copied"
@copy="copyRssLink(rssLink)"
/>
<!-- Advanced settings -->
<advanced-section v-model:open="showAdvanced">
<bangumi-filter-field v-model="localRule.filter" />
<bangumi-offset-field
v-model="localRule.season_offset"
:label="$t('homepage.rule.season_offset')"
/>
<bangumi-offset-field
v-model="localRule.episode_offset"
:label="$t('homepage.rule.episode_offset')"
/>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.air_weekday')
}}</label>
<NSelect
:value="localRule.air_weekday ?? null"
:options="weekdayOptions"
clearable
size="small"
:placeholder="$t('calendar.unknown')"
class="weekday-select"
@update:value="onWeekdayChange"
/>
</div>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.episode_type')
}}</label>
<NSelect
v-model:value="localRule.episode_type"
:options="episodeTypeOptions"
size="small"
class="weekday-select"
/>
</div>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.preferred_group')
}}</label>
<ab-input
:model-value="localRule.preferred_group ?? ''"
type="text"
class="preferred-input"
placeholder="ANi"
@update:model-value="localRule.preferred_group = String($event)"
/>
</div>
<div class="weekday-row">
<label class="weekday-label">{{
$t('homepage.rule.preferred_resolution')
}}</label>
<NSelect
v-model:value="localRule.preferred_resolution"
:options="resolutionOptions"
clearable
filterable
tag
size="small"
:placeholder="$t('homepage.rule.auto_detect')"
class="weekday-select"
/>
</div>
<p class="preferred-hint">
{{ $t('homepage.rule.preferred_hint') }}
</p>
</advanced-section>
</div>
<!-- 删除确认嵌套在主弹窗组件树内headlessui 才会把外层的
Escape/遮罩点击挂起只关闭内层 -->
<!-- Delete confirmation dialog -->
<ab-modal
v-model:show="deleteFileDialog.show"
size="sm"
:title="$t('homepage.rule.delete')"
>
<p class="delete-message">
{{ $t('homepage.rule.delete_confirm') }}
</p>
<NCheckbox v-model:checked="deleteLocalFiles" class="delete-files-option">
{{ $t('homepage.rule.delete_files_label') }}
</NCheckbox>
<template #footer>
<ab-button size="sm" @click="deleteFileDialog.show = false">
{{ $t('homepage.rule.cancel_btn') }}
</ab-button>
<ab-button
size="sm"
variant="danger"
@click="emitDeleteFile(deleteLocalFiles)"
>
{{ $t('homepage.rule.delete') }}
</ab-button>
</template>
</ab-modal>
<template #footer>
<ab-button
v-if="localRule.archived"
size="sm"
@click="emitUnarchive"
>
<ab-button v-if="localRule.archived" size="sm" @click="emitUnarchive">
{{ $t('homepage.rule.unarchive') }}
</ab-button>
<ab-button v-else size="sm" @click="emitArchive">
@@ -362,37 +386,6 @@ function emitUnarchive() {
{{ $t('homepage.rule.apply') }}
</ab-button>
</template>
</ab-modal>
<!-- Delete confirmation dialog -->
<ab-modal
v-model:show="deleteFileDialog.show"
size="sm"
:title="$t('homepage.rule.delete')"
>
<p class="delete-message">
{{ $t('homepage.rule.delete_confirm') }}
</p>
<NCheckbox
v-model:checked="deleteLocalFiles"
class="delete-files-option"
>
{{ $t('homepage.rule.delete_files_label') }}
</NCheckbox>
<template #footer>
<ab-button size="sm" @click="deleteFileDialog.show = false">
{{ $t('homepage.rule.cancel_btn') }}
</ab-button>
<ab-button
size="sm"
variant="danger"
@click="emitDeleteFile(deleteLocalFiles)"
>
{{ $t('homepage.rule.delete') }}
</ab-button>
</template>
</ab-modal>
</template>
@@ -543,5 +536,4 @@ function emitUnarchive() {
font-size: 11px;
color: var(--color-text-secondary);
}
</style>

View File

@@ -66,6 +66,7 @@ const selectValue = computed<string | number | null>({
:placeholder="prop?.placeholder"
:min="prop?.min"
:max="prop?.max"
:autocomplete="prop?.autocomplete"
></AbInput>
<div v-else-if="type === 'dynamic-tags'" class="dynamic-tags-wrapper">

View File

@@ -24,11 +24,7 @@ const proxyValue = computed({
<div class="advanced-row">
<label class="advanced-label">{{ label }}</label>
<div class="advanced-control offset-controls">
<ab-input
v-model="proxyValue"
type="number"
class="offset-input"
/>
<ab-input v-model="proxyValue" type="number" class="offset-input" />
<slot name="action" />
</div>
</div>

View File

@@ -3,24 +3,24 @@ import { mount } from '@vue/test-utils';
import AbButton from '../ab-button.vue';
describe('ab-button', () => {
it('renders default slot content', () => {
it('should render default slot content', () => {
const wrapper = mount(AbButton, { slots: { default: 'Add RSS' } });
expect(wrapper.text()).toContain('Add RSS');
});
it('emits click when clicked', async () => {
it('should emit click when clicked', async () => {
const wrapper = mount(AbButton);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toHaveLength(1);
});
it('does not emit click when disabled', async () => {
it('should not emit click when disabled', async () => {
const wrapper = mount(AbButton, { props: { disabled: true } });
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeUndefined();
});
it('disables the button and shows a spinner when loading', async () => {
it('should disable the button and show a spinner when loading', async () => {
const wrapper = mount(AbButton, { props: { loading: true } });
expect(wrapper.attributes('disabled')).toBeDefined();
expect(wrapper.find('.ab-btn-spin').exists()).toBe(true);
@@ -28,12 +28,12 @@ describe('ab-button', () => {
expect(wrapper.emitted('click')).toBeUndefined();
});
it('applies the variant class', () => {
it('should apply the variant class', () => {
const wrapper = mount(AbButton, { props: { variant: 'danger' } });
expect(wrapper.classes()).toContain('ab-btn--danger');
});
it('defaults to type="button" so it does not submit forms', () => {
it('should default to type="button" so it does not submit forms', () => {
const wrapper = mount(AbButton);
expect(wrapper.attributes('type')).toBe('button');
});

View File

@@ -8,34 +8,30 @@ async function tick() {
}
describe('useConfirm + ab-confirm-host', () => {
it('resolves true when the confirm button is clicked', async () => {
it('should resolve true when the confirm button is clicked', async () => {
const wrapper = mount(AbConfirmHost, { attachTo: document.body });
const { confirm } = useConfirm();
const result = confirm({ title: 'Delete rule?', body: 'Files stay.' });
await tick();
expect(document.body.textContent).toContain('Delete rule?');
(
document.querySelector('.ab-confirm-ok') as HTMLButtonElement
).click();
(document.querySelector('.ab-confirm-ok') as HTMLButtonElement).click();
await expect(result).resolves.toBe(true);
wrapper.unmount();
});
it('resolves false when cancelled', async () => {
it('should resolve false when cancelled', async () => {
const wrapper = mount(AbConfirmHost, { attachTo: document.body });
const { confirm } = useConfirm();
const result = confirm({ title: 'Reset log?' });
await tick();
(
document.querySelector('.ab-confirm-cancel') as HTMLButtonElement
).click();
(document.querySelector('.ab-confirm-cancel') as HTMLButtonElement).click();
await expect(result).resolves.toBe(false);
wrapper.unmount();
});
it('renders danger variant confirm button when danger is set', async () => {
it('should render danger variant confirm button when danger is set', async () => {
const wrapper = mount(AbConfirmHost, { attachTo: document.body });
const { confirm } = useConfirm();
const result = confirm({ title: 'Delete rule?', danger: true });

View File

@@ -3,10 +3,12 @@ import { defineComponent, h, inject } from 'vue';
import { mount } from '@vue/test-utils';
import AbField, { abFieldInjectionKey } from '../ab-field.vue';
// A control that reads the field context the way ab-input/ab-select do
// A control that reads the field context the way ab-input does
const FakeControl = defineComponent({
setup() {
const field = inject(abFieldInjectionKey, null);
// 与 ab-input 相同:认领 controlId字段才渲染真正的 <label for>
if (field) field.adopted.value = true;
return () =>
h('input', {
id: field?.controlId,
@@ -16,6 +18,13 @@ const FakeControl = defineComponent({
},
});
// naive-ui 类控件:不认领 id
const OpaqueControl = defineComponent({
setup() {
return () => h('div', { class: 'fake-select' });
},
});
function mountField(props = {}) {
return mount(AbField, {
props: { label: 'RSS link', ...props },
@@ -24,20 +33,31 @@ function mountField(props = {}) {
}
describe('ab-field', () => {
it('associates the label with the control via for/id', () => {
it('should associate the label with the control via for/id', async () => {
const wrapper = mountField();
// 控件在挂载期认领 idlabel 在下一个 tick 升级为 <label for>
await wrapper.vm.$nextTick();
const label = wrapper.find('label');
const input = wrapper.find('input');
expect(label.attributes('for')).toBeTruthy();
expect(label.attributes('for')).toBe(input.attributes('id'));
});
it('shows a required marker when required', () => {
it('should render a span instead of label[for] when the control cannot take an id', () => {
const wrapper = mount(AbField, {
props: { label: 'Parser' },
slots: { default: OpaqueControl },
});
expect(wrapper.find('label').exists()).toBe(false);
expect(wrapper.find('.ab-field-label').attributes('for')).toBeUndefined();
});
it('should show a required marker when required', () => {
const wrapper = mountField({ required: true });
expect(wrapper.find('.ab-field-required').exists()).toBe(true);
});
it('wires description and error into aria-describedby', () => {
it('should wire description and error into aria-describedby', () => {
const wrapper = mountField({
description: 'Mikan personal feed.',
error: 'Must be a URL',
@@ -51,7 +71,7 @@ describe('ab-field', () => {
expect(wrapper.text()).toContain('Must be a URL');
});
it('marks the control invalid when error is set', () => {
it('should mark the control invalid when error is set', () => {
const wrapper = mountField({ error: 'Must be a URL' });
expect(wrapper.find('input').attributes('aria-invalid')).toBe('true');
});

View File

@@ -3,18 +3,18 @@ import { mount } from '@vue/test-utils';
import AbIconButton from '../ab-icon-button.vue';
describe('ab-icon-button', () => {
it('exposes the label as aria-label', () => {
it('should expose the label as aria-label', () => {
const wrapper = mount(AbIconButton, { props: { label: 'Delete rule' } });
expect(wrapper.attributes('aria-label')).toBe('Delete rule');
});
it('emits click when clicked', async () => {
it('should emit click when clicked', async () => {
const wrapper = mount(AbIconButton, { props: { label: 'Edit' } });
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toHaveLength(1);
});
it('does not emit click when disabled', async () => {
it('should not emit click when disabled', async () => {
const wrapper = mount(AbIconButton, {
props: { label: 'Edit', disabled: true },
});

View File

@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import AbInput from '../ab-input.vue';
describe('ab-input', () => {
it('binds v-model', async () => {
it('should bind v-model', async () => {
const wrapper = mount(AbInput, {
props: {
modelValue: 'abc',
@@ -15,14 +15,14 @@ describe('ab-input', () => {
expect(wrapper.props('modelValue')).toBe('https://mikanani.me');
});
it('applies the error class when error is set', () => {
it('should apply the error class when error is set', () => {
const wrapper = mount(AbInput, {
props: { modelValue: '', error: true },
});
expect(wrapper.classes()).toContain('ab-input--error');
});
it('clears the value via the clear button when clearable', async () => {
it('should clear the value via the clear button when clearable', async () => {
const wrapper = mount(AbInput, {
props: {
modelValue: 'abc',
@@ -35,7 +35,7 @@ describe('ab-input', () => {
expect(wrapper.props('modelValue')).toBe('');
});
it('toggles password visibility', async () => {
it('should toggle password visibility', async () => {
const wrapper = mount(AbInput, {
props: { modelValue: 'secret', type: 'password' },
});
@@ -44,7 +44,7 @@ describe('ab-input', () => {
expect(wrapper.find('input').attributes('type')).toBe('text');
});
it('emits numbers for type=number', async () => {
it('should emit numbers for type=number', async () => {
const wrapper = mount(AbInput, {
props: { modelValue: 0, type: 'number' },
});

View File

@@ -9,7 +9,7 @@ const items = [
];
describe('ab-list', () => {
it('renders one row per item via the row slot', () => {
it('should render one row per item via the row slot', () => {
const wrapper = mount(AbList, {
props: { items },
slots: { row: `<template #row="{ item }">{{ item.name }}</template>` },
@@ -18,7 +18,7 @@ describe('ab-list', () => {
expect(wrapper.text()).toContain('怪兽8号');
});
it('emits selection updates when a checkbox is toggled', async () => {
it('should emit selection updates when a checkbox is toggled', async () => {
const wrapper = mount(AbList, {
props: { items, selectable: true, selected: [] },
});
@@ -28,7 +28,7 @@ describe('ab-list', () => {
expect(wrapper.emitted('update:selected')?.at(-1)).toEqual([[1]]);
});
it('select-all toggles every row', async () => {
it('should toggle every row when select-all is clicked', async () => {
const wrapper = mount(AbList, {
props: { items, selectable: true, selected: [] },
});
@@ -36,14 +36,14 @@ describe('ab-list', () => {
expect(wrapper.emitted('update:selected')?.at(-1)).toEqual([[1, 2, 3]]);
});
it('shows skeleton rows while loading', () => {
it('should show skeleton rows while loading', () => {
const wrapper = mount(AbList, {
props: { items: [], loading: true },
});
expect(wrapper.find('.ab-skeleton').exists()).toBe(true);
});
it('shows the empty slot when there are no items', () => {
it('should show the empty slot when there are no items', () => {
const wrapper = mount(AbList, {
props: { items: [] },
slots: { empty: '<p id="empty-msg">nothing</p>' },
@@ -51,7 +51,7 @@ describe('ab-list', () => {
expect(wrapper.find('#empty-msg').exists()).toBe(true);
});
it('emits row-click with the item', async () => {
it('should emit row-click with the item', async () => {
const wrapper = mount(AbList, { props: { items } });
await wrapper.findAll('.ab-list-row')[2].trigger('click');
expect(wrapper.emitted('row-click')?.[0]?.[0]).toMatchObject({ id: 3 });

View File

@@ -11,7 +11,7 @@ function items(handler = vi.fn()) {
}
describe('ab-menu', () => {
it('renders item labels (string and function) after opening', async () => {
it('should render item labels (string and function) after opening', async () => {
const wrapper = mount(AbMenu, {
props: { items: items() },
slots: { trigger: '<button type="button">Actions</button>' },
@@ -22,7 +22,7 @@ describe('ab-menu', () => {
expect(wrapper.text()).toContain('Archive rule');
});
it('calls the item handler and emits select on item click', async () => {
it('should call the item handler and emits select on item click', async () => {
const handler = vi.fn();
const wrapper = mount(AbMenu, {
props: { items: items(handler) },
@@ -37,7 +37,7 @@ describe('ab-menu', () => {
});
});
it('marks danger items with the danger class', async () => {
it('should mark danger items with the danger class', async () => {
const wrapper = mount(AbMenu, {
props: { items: items() },
slots: { trigger: '<button type="button">Actions</button>' },

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, ref } from 'vue';
import { mount } from '@vue/test-utils';
import AbModal from '../ab-modal.vue';
@@ -21,26 +22,26 @@ async function mountModal(props = {}, slots = {}) {
}
describe('ab-modal', () => {
it('renders title and body when shown', async () => {
it('should render title and body when shown', async () => {
const wrapper = await mountModal();
expect(document.body.textContent).toContain('Delete rule?');
expect(document.body.textContent).toContain('body text');
wrapper.unmount();
});
it('renders nothing when show is false', async () => {
it('should render nothing when show is false', async () => {
const wrapper = await mountModal({ show: false });
expect(document.body.textContent).not.toContain('Delete rule?');
wrapper.unmount();
});
it('exposes dialog semantics', async () => {
it('should expose dialog semantics', async () => {
const wrapper = await mountModal();
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
wrapper.unmount();
});
it('renders the footer slot', async () => {
it('should render the footer slot', async () => {
const wrapper = await mountModal(
{},
{ footer: '<button id="ok">OK</button>' }
@@ -49,7 +50,7 @@ describe('ab-modal', () => {
wrapper.unmount();
});
it('emits update:show false when the close button is clicked', async () => {
it('should emit update:show false when the close button is clicked', async () => {
const wrapper = await mountModal();
const close = document.querySelector(
'.ab-modal-close'
@@ -61,9 +62,42 @@ describe('ab-modal', () => {
wrapper.unmount();
});
it('hides the close button when closable is false', async () => {
it('should hide the close button when closable is false', async () => {
const wrapper = await mountModal({ closable: false });
expect(document.querySelector('.ab-modal-close')).toBeNull();
wrapper.unmount();
});
it('should keep the outer modal open when clicking inside a nested modal', async () => {
const Host = defineComponent({
components: { AbModal },
setup() {
const outer = ref(true);
const inner = ref(true);
return { outer, inner };
},
template: `
<AbModal v-model:show="outer" title="outer">
<p>outer body</p>
<AbModal v-model:show="inner" title="inner">
<p id="inner-body">inner body</p>
</AbModal>
</AbModal>`,
});
const wrapper = mount(Host, { attachTo: document.body });
await new Promise((resolve) => setTimeout(resolve));
const innerBody = document.querySelector('#inner-body') as HTMLElement;
expect(innerBody).not.toBeNull();
// headlessui 的 outside-click 走 pointer/mouse 事件序列
for (const type of ['pointerdown', 'mousedown', 'click']) {
innerBody.dispatchEvent(
new MouseEvent(type, { bubbles: true, cancelable: true })
);
}
await new Promise((resolve) => setTimeout(resolve));
expect((wrapper.vm as any).outer).toBe(true);
wrapper.unmount();
});
});

View File

@@ -9,7 +9,7 @@ const options = [
];
describe('ab-segmented', () => {
it('marks the selected option', () => {
it('should mark the selected option', () => {
const wrapper = mount(AbSegmented, {
props: { options, value: 'airing' },
});
@@ -18,7 +18,7 @@ describe('ab-segmented', () => {
expect(tabs[0].attributes('aria-selected')).toBe('false');
});
it('updates v-model on click', async () => {
it('should update v-model on click', async () => {
const wrapper = mount(AbSegmented, {
props: { options, value: 'all' },
});
@@ -26,7 +26,7 @@ describe('ab-segmented', () => {
expect(wrapper.emitted('update:value')?.[0]).toEqual(['archived']);
});
it('moves selection with arrow keys', async () => {
it('should move selection with arrow keys', async () => {
const wrapper = mount(AbSegmented, {
props: { options, value: 'all' },
});

View File

@@ -8,7 +8,7 @@ const options = [
];
describe('ab-split-button', () => {
it('shows the label of the selected value on the main button', () => {
it('should show the label of the selected value on the main button', () => {
const wrapper = mount(AbSplitButton, {
props: { options, value: 'collect' },
});
@@ -17,7 +17,7 @@ describe('ab-split-button', () => {
);
});
it('emits click with the current value', async () => {
it('should emit click with the current value', async () => {
const wrapper = mount(AbSplitButton, {
props: { options, value: 'enable' },
});
@@ -25,7 +25,7 @@ describe('ab-split-button', () => {
expect(wrapper.emitted('click')?.[0]).toEqual(['enable']);
});
it('updates v-model when an option is selected from the menu', async () => {
it('should update v-model when an option is selected from the menu', async () => {
const wrapper = mount(AbSplitButton, {
props: { options, value: 'enable' },
attachTo: document.body,
@@ -35,7 +35,7 @@ describe('ab-split-button', () => {
expect(wrapper.emitted('update:value')?.[0]).toEqual(['collect']);
});
it('does not emit click while loading', async () => {
it('should not emit click while loading', async () => {
const wrapper = mount(AbSplitButton, {
props: { options, value: 'enable', loading: true },
});

View File

@@ -3,26 +3,26 @@ import { mount } from '@vue/test-utils';
import AbTag from '../ab-tag.vue';
describe('ab-tag', () => {
it('renders the title text', () => {
it('should render the title text', () => {
const wrapper = mount(AbTag, {
props: { type: 'success', title: '葬送的芙莉莲 第二季' },
});
expect(wrapper.text()).toContain('葬送的芙莉莲 第二季');
});
it('applies the semantic type class', () => {
it('should apply the semantic type class', () => {
const wrapper = mount(AbTag, {
props: { type: 'warning', title: 'Needs review' },
});
expect(wrapper.classes()).toContain('ab-tag--warning');
});
it('does not render a close control by default', () => {
it('should not render a close control by default', () => {
const wrapper = mount(AbTag, { props: { type: 'info', title: 'S02E08' } });
expect(wrapper.find('.ab-tag-close').exists()).toBe(false);
});
it('emits close when the close control is clicked', async () => {
it('should emit close when the close control is clicked', async () => {
const wrapper = mount(AbTag, {
props: { type: 'info', title: 'S02E08', closable: true },
});

View File

@@ -43,17 +43,21 @@ function handleViewportResize() {
}
// Set up visualViewport listeners when sheet is shown
watch(() => props.show, (isVisible) => {
if (isVisible && window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize);
window.visualViewport.addEventListener('scroll', handleViewportResize);
handleViewportResize();
} else if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleViewportResize);
window.visualViewport.removeEventListener('scroll', handleViewportResize);
keyboardHeight.value = 0;
}
}, { immediate: true });
watch(
() => props.show,
(isVisible) => {
if (isVisible && window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize);
window.visualViewport.addEventListener('scroll', handleViewportResize);
handleViewportResize();
} else if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleViewportResize);
window.visualViewport.removeEventListener('scroll', handleViewportResize);
keyboardHeight.value = 0;
}
},
{ immediate: true }
);
onUnmounted(() => {
if (window.visualViewport) {

View File

@@ -31,11 +31,7 @@ function onClick(event: MouseEvent) {
<template>
<button
class="ab-btn"
:class="[
`ab-btn--${variant}`,
`ab-btn--${size}`,
block && 'ab-btn--block',
]"
:class="[`ab-btn--${variant}`, `ab-btn--${size}`, block && 'ab-btn--block']"
:type="type"
:disabled="disabled || loading"
:aria-busy="loading || undefined"
@@ -87,8 +83,9 @@ function onClick(event: MouseEvent) {
padding: 0 10px;
font-size: 13px;
// 触屏统一保证 44px 命中区PRODUCT.md 无障碍约定)
@include forTouch {
height: 36px;
height: var(--touch-target);
}
}

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { computed, provide, useId } from 'vue';
import type { ComputedRef, InjectionKey } from 'vue';
import { computed, provide, ref, useId } from 'vue';
import type { ComputedRef, InjectionKey, Ref } from 'vue';
export interface AbFieldContext {
/** 原生控件应设置的 id配合 <label for> */
controlId: string;
/** 消费 controlId 的控件置 true —— 只有这时才渲染真正的 <label for> */
adopted: Ref<boolean>;
/** 标签文本节点 id无法用 for 关联的组件用 aria-labelledby */
labelId: string;
/** description/error 节点 id 列表,控件应设为 aria-describedby */
@@ -53,22 +55,34 @@ const describedBy = computed(() => {
const invalid = computed(() => Boolean(props.error));
provide(abFieldInjectionKey, { controlId, labelId, describedBy, invalid });
// ab-input 等原生控件在挂载时认领 controlIdnaive 组件无法接收 id
// 走 aria-labelledby此时渲染 span 而非悬空的 label[for]
const adopted = ref(false);
provide(abFieldInjectionKey, {
controlId,
adopted,
labelId,
describedBy,
invalid,
});
</script>
<template>
<div class="ab-field" :class="{ 'ab-field--invalid': invalid }">
<div class="ab-field-main">
<div class="ab-field-heading">
<label :id="labelId" class="ab-field-label" :for="controlId">
<Component
:is="adopted ? 'label' : 'span'"
:id="labelId"
class="ab-field-label"
:for="adopted ? controlId : undefined"
>
{{ labelText }}
<span
v-if="required"
class="ab-field-required"
aria-hidden="true"
<span v-if="required" class="ab-field-required" aria-hidden="true"
>*</span
>
</label>
</Component>
<p v-if="description" :id="descriptionId" class="ab-field-description">
{{ description }}
</p>

View File

@@ -17,6 +17,7 @@ const props = withDefaults(
max?: number;
step?: number;
maxlength?: number;
autocomplete?: string;
}>(),
{
type: 'text',
@@ -29,12 +30,14 @@ const props = withDefaults(
max: undefined,
step: undefined,
maxlength: undefined,
autocomplete: undefined,
}
);
const model = defineModel<string | number>({ default: '' });
const field = inject(abFieldInjectionKey, null);
if (field) field.adopted.value = true;
const revealed = ref(false);
@@ -77,6 +80,7 @@ function clear() {
:max="max"
:step="step"
:maxlength="maxlength"
:autocomplete="autocomplete"
:aria-label="ariaLabel"
:aria-describedby="field?.describedBy.value"
:aria-invalid="invalid || undefined"
@@ -122,7 +126,13 @@ function clear() {
stroke-width="2"
stroke-linejoin="round"
/>
<circle cx="12" cy="12" r="2.5" stroke="currentColor" stroke-width="2" />
<circle
cx="12"
cy="12"
r="2.5"
stroke="currentColor"
stroke-width="2"
/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" width="14" height="14">
<path

View File

@@ -105,7 +105,7 @@ function close() {
v-if="closable && showClose"
class="ab-modal-close"
size="sm"
:label="$t('common.cancel')"
:label="$t('common.close')"
@click="close"
>
<Close theme="outline" size="16" />

View File

@@ -215,11 +215,7 @@ function handleCancel() {
<!-- Footer -->
<footer class="dialog-footer">
<ab-button
size="sm"
variant="secondary"
@click="handleCancel"
>
<ab-button size="sm" variant="secondary" @click="handleCancel">
{{ t('offset.cancel') }}
</ab-button>
<ab-button variant="primary" size="sm" @click="handleKeep">

View File

@@ -26,8 +26,13 @@ const startY = ref(0);
const pullStyle = computed(() => {
if (pullDistance.value > 0) {
return {
transform: `translateY(${Math.min(pullDistance.value, props.threshold * 1.5)}px)`,
transition: isPulling.value ? 'none' : 'transform var(--transition-normal)',
transform: `translateY(${Math.min(
pullDistance.value,
props.threshold * 1.5
)}px)`,
transition: isPulling.value
? 'none'
: 'transform var(--transition-normal)',
};
}
return {
@@ -101,10 +106,7 @@ function onTouchEnd() {
aria-live="polite"
:aria-label="loading ? $t('common.loading') : undefined"
>
<div
v-if="loading"
class="ab-pull-refresh__spinner"
/>
<div v-if="loading" class="ab-pull-refresh__spinner" />
<svg
v-else
class="ab-pull-refresh__arrow"

View File

@@ -82,8 +82,7 @@ function onKeydown(event: KeyboardEvent, index: number) {
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: color var(--transition-fast),
border-color var(--transition-fast);
transition: color var(--transition-fast), border-color var(--transition-fast);
@include forTouch {
padding: 10px 2px;

View File

@@ -24,7 +24,6 @@ export const Template: Story = {
];
return { value, options };
},
template:
'<ab-split-button v-model:value="value" :options="options" />',
template: '<ab-split-button v-model:value="value" :options="options" />',
}),
};

View File

@@ -19,12 +19,14 @@ defineEmits<{ close: [] }>();
<template>
<span class="ab-tag" :class="`ab-tag--${type}`">
<span class="ab-tag-text"><slot>{{ title }}</slot></span>
<span class="ab-tag-text"
><slot>{{ title }}</slot></span
>
<button
v-if="closable"
type="button"
class="ab-tag-close"
:aria-label="$t('common.cancel')"
:aria-label="$t('common.remove')"
@click.stop="$emit('close')"
>
<svg viewBox="0 0 24 24" fill="none" width="10" height="10">
@@ -52,8 +54,7 @@ defineEmits<{ close: [] }>();
color: var(--color-text);
font-size: 12px;
font-weight: 500;
transition: border-color var(--transition-fast),
color var(--transition-fast);
transition: border-color var(--transition-fast), color var(--transition-fast);
// 语义色方块标记
&::before {

View File

@@ -36,11 +36,7 @@ const show = defineModel<boolean>('show', { default: false });
</div>
<div class="rule-list-item-tags">
<ab-tag v-if="rule.dpi" :title="rule.dpi" type="info" />
<ab-tag
v-if="rule.subtitle"
:title="rule.subtitle"
type="info"
/>
<ab-tag v-if="rule.subtitle" :title="rule.subtitle" type="info" />
<ab-tag v-if="rule.source" :title="rule.source" type="info" />
</div>
<div

View File

@@ -99,7 +99,6 @@ function relativeTime(iso: string): string {
v-if="unreadCount > 0"
variant="ghost"
size="sm"
class="notification-action"
@click="store.markAllRead()"
>
{{ $t('notifications.mark_all_read') }}
@@ -108,7 +107,6 @@ function relativeTime(iso: string): string {
v-if="messages.length > 0"
variant="danger"
size="sm"
class="notification-action"
@click="onClearClick"
>
{{
@@ -267,28 +265,6 @@ function relativeTime(iso: string): string {
gap: 4px;
}
.notification-action {
background: transparent;
border: none;
cursor: pointer;
font-size: 12px;
color: var(--color-primary);
padding: 4px 6px;
border-radius: var(--radius-sm);
&:hover {
background: var(--color-primary-light);
}
&--danger {
color: var(--color-danger, #ef4444);
&:hover {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
}
}
}
.notification-empty {
padding: 32px 14px;
text-align: center;

View File

@@ -173,7 +173,7 @@ function handleConfirm() {
<h2 class="confirm-title">{{ $t('search.confirm.title') }}</h2>
<ab-icon-button
class="close-btn"
:label="$t('common.cancel')"
:label="$t('common.close')"
@click="emit('cancel')"
>
<Close theme="outline" size="18" />

View File

@@ -681,7 +681,7 @@ function handleFilterClick(
<ab-icon-button
class="modal-close-btn"
:label="$t('common.cancel')"
:label="$t('common.close')"
@click="handleClose"
>
<Close theme="outline" size="20" />

View File

@@ -60,22 +60,46 @@ const providerOptions = computed<SelectGroupOption[]>(() => {
const installed = providerList.value.filter(
(p) => !p.builtin && p.auth_kind === 'api_key'
);
const subscription = providerList.value.filter((p) => !p.builtin && p.auth_kind !== 'api_key');
const subscription = providerList.value.filter(
(p) => !p.builtin && p.auth_kind !== 'api_key'
);
const installedIds = new Set(providerList.value.map((p) => p.id));
const downloadable = KNOWN_DOWNLOADABLE.filter((p) => !installedIds.has(p.id));
const downloadable = KNOWN_DOWNLOADABLE.filter(
(p) => !installedIds.has(p.id)
);
const groups: SelectGroupOption[] = [];
const toOpt = (p: { id: string; display_name: string }) => ({
label: p.display_name,
value: p.id,
});
if (builtin.length)
groups.push({ type: 'group', label: t('config.llm_set.group_builtin'), key: 'builtin', children: builtin.map(toOpt) });
groups.push({
type: 'group',
label: t('config.llm_set.group_builtin'),
key: 'builtin',
children: builtin.map(toOpt),
});
if (installed.length)
groups.push({ type: 'group', label: t('config.llm_set.group_installed'), key: 'installed', children: installed.map(toOpt) });
groups.push({
type: 'group',
label: t('config.llm_set.group_installed'),
key: 'installed',
children: installed.map(toOpt),
});
if (subscription.length)
groups.push({ type: 'group', label: t('config.llm_set.group_subscription'), key: 'subscription', children: subscription.map(toOpt) });
groups.push({
type: 'group',
label: t('config.llm_set.group_subscription'),
key: 'subscription',
children: subscription.map(toOpt),
});
if (downloadable.length)
groups.push({ type: 'group', label: t('config.llm_set.group_available'), key: 'available', children: downloadable.map(toOpt) });
groups.push({
type: 'group',
label: t('config.llm_set.group_available'),
key: 'available',
children: downloadable.map(toOpt),
});
return groups;
});
@@ -276,9 +300,12 @@ const tuningItems = computed(() => [
account: selected.account_label || selected.display_name,
})
}}</span>
<ab-button size="sm" variant="ghost" @click="onDisconnect(selected.id)">{{
t('config.llm_set.disconnect')
}}</ab-button>
<ab-button
size="sm"
variant="ghost"
@click="onDisconnect(selected.id)"
>{{ t('config.llm_set.disconnect') }}</ab-button
>
</div>
<ab-button
v-else
@@ -337,9 +364,12 @@ const tuningItems = computed(() => [
<div v-else class="llm-risk">
<p class="llm-risk-text">{{ t('config.llm_set.risk_notice') }}</p>
<div class="llm-risk-actions">
<ab-button size="sm" variant="secondary" @click="confirmRisk = false">{{
t('config.llm_set.risk_cancel')
}}</ab-button>
<ab-button
size="sm"
variant="secondary"
@click="confirmRisk = false"
>{{ t('config.llm_set.risk_cancel') }}</ab-button
>
<ab-button
variant="danger"
size="sm"
@@ -409,7 +439,7 @@ const tuningItems = computed(() => [
color: var(--color-text-secondary);
font-size: 12px;
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
border-color var(--transition-normal);
}
.llm-config {

View File

@@ -383,7 +383,6 @@ function getFieldsForType(type: string) {
>
{{ testResult.message }}
</div>
</div>
<template #footer>
@@ -393,11 +392,7 @@ function getFieldsForType(type: string) {
:disabled="testingIndex === -999"
@click="testNewProvider"
>
<div
v-if="testingIndex === -999"
i-carbon-circle-dash
animate-spin
/>
<div v-if="testingIndex === -999" i-carbon-circle-dash animate-spin />
<div v-else i-carbon-play />
{{ $t('config.notification_set.test') }}
</ab-button>
@@ -452,7 +447,6 @@ function getFieldsForType(type: string) {
>
{{ testResult.message }}
</div>
</div>
<template #footer>
@@ -462,11 +456,7 @@ function getFieldsForType(type: string) {
:disabled="testingIndex === -999"
@click="testNewProvider"
>
<div
v-if="testingIndex === -999"
i-carbon-circle-dash
animate-spin
/>
<div v-if="testingIndex === -999" i-carbon-circle-dash animate-spin />
<div v-else i-carbon-play />
{{ $t('config.notification_set.test') }}
</ab-button>

View File

@@ -1,9 +1,20 @@
<script lang="ts" setup>
import { NPopconfirm } from 'naive-ui';
import { Delete } from '@icon-park/vue-next';
import { useConfirm } from '@/hooks/useConfirm';
import type { PasskeyItem } from '#/passkey';
const { t } = useMyI18n();
const { confirm } = useConfirm();
async function onDeleteClick(passkey: PasskeyItem) {
const ok = await confirm({
title: t('passkey.delete'),
body: t('passkey.delete_confirm'),
confirmText: t('passkey.delete'),
danger: true,
});
if (ok) handleDelete(passkey);
}
const {
passkeys,
loading,
@@ -110,23 +121,14 @@ function formatDate(dateString: string | null): string {
{{ $t('passkey.synced') }}
</div>
</div>
<NPopconfirm
:positive-text="$t('passkey.delete')"
:negative-text="$t('config.cancel')"
:positive-button-props="{ type: 'error' }"
@positive-click="handleDelete(passkey)"
<ab-icon-button
size="sm"
class="passkey-delete"
:label="$t('passkey.delete')"
@click="onDeleteClick(passkey)"
>
<template #trigger>
<ab-button
size="sm"
variant="danger"
:aria-label="$t('passkey.delete')"
>
<Delete size="16" />
</ab-button>
</template>
{{ $t('passkey.delete_confirm') }}
</NPopconfirm>
<Delete size="16" />
</ab-icon-button>
</div>
</div>
@@ -164,7 +166,6 @@ function formatDate(dateString: string | null): string {
<div text-14 text-gray-500>
{{ $t('passkey.register_hint') }}
</div>
</div>
<template #footer>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { NPopconfirm } from 'naive-ui';
import { Delete, EditTwo, Plus } from '@icon-park/vue-next';
import { useConfirm } from '@/hooks/useConfirm';
interface SearchProvider {
name: string;
@@ -8,6 +8,17 @@ interface SearchProvider {
}
const { t } = useMyI18n();
const { confirm } = useConfirm();
async function onDeleteClick(index: number) {
const ok = await confirm({
title: t('config.search_provider_set.remove'),
body: t('config.search_provider_set.delete_confirm'),
confirmText: t('config.search_provider_set.remove'),
danger: true,
});
if (ok) handleDelete(index);
}
const message = useMessage();
// State
@@ -179,24 +190,15 @@ function validateUrl(url: string): boolean {
>
<EditTwo size="16" />
</ab-button>
<NPopconfirm
<ab-icon-button
v-if="!isDefaultProvider(provider.name)"
:positive-text="$t('config.search_provider_set.remove')"
:negative-text="$t('config.cancel')"
:positive-button-props="{ type: 'error' }"
@positive-click="handleDelete(index)"
size="sm"
class="provider-delete"
:label="$t('config.search_provider_set.remove')"
@click="onDeleteClick(index)"
>
<template #trigger>
<ab-button
size="sm"
variant="danger"
:aria-label="$t('config.search_provider_set.remove')"
>
<Delete size="16" />
</ab-button>
</template>
{{ $t('config.search_provider_set.delete_confirm') }}
</NPopconfirm>
<Delete size="16" />
</ab-icon-button>
</div>
</div>
</div>
@@ -244,7 +246,6 @@ function validateUrl(url: string): boolean {
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
{{ $t('config.search_provider_set.url_missing_placeholder') }}
</div>
</div>
<template #footer>
@@ -295,7 +296,6 @@ function validateUrl(url: string): boolean {
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
{{ $t('config.search_provider_set.url_missing_placeholder') }}
</div>
</div>
<template #footer>
@@ -388,4 +388,12 @@ function validateUrl(url: string): boolean {
background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent);
border-radius: 6px;
}
.provider-delete {
color: var(--color-danger);
&:hover:not(:disabled) {
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
}
}
</style>

View File

@@ -55,7 +55,9 @@ function startPolling() {
const status = await apiLLM.getAuthStatus(props.providerId);
if (status.connected) {
stopPolling();
message.success(t('config.llm_set.connected_as', { account: status.account_label }));
message.success(
t('config.llm_set.connected_as', { account: status.account_label })
);
emit('connected');
close();
}
@@ -74,7 +76,9 @@ async function submitCode() {
state: challenge.value.state,
code: pastedCode.value.trim(),
});
message.success(t('config.llm_set.connected_as', { account: res.account_label }));
message.success(
t('config.llm_set.connected_as', { account: res.account_label })
);
emit('connected');
close();
} catch {

View File

@@ -1,14 +1,29 @@
<script lang="ts" setup>
import {
NPopconfirm,
NProgress,
NSpin,
NSwitch,
NTag,
} from 'naive-ui';
import { NProgress, NSpin, NSwitch, NTag } from 'naive-ui';
import { useConfirm } from '@/hooks/useConfirm';
import type { UpdateChannel, UpdateInfo } from '#/update';
const { t } = useMyI18n();
const { confirm } = useConfirm();
async function onApplyClick() {
const ok = await confirm({
title: t('update.confirm_title'),
body: t('update.confirm_body'),
confirmText: t('update.update_now'),
});
if (ok) onApply();
}
async function onRollbackClick() {
const ok = await confirm({
title: t('update.rollback'),
body: t('update.rollback_confirm'),
confirmText: t('update.rollback'),
danger: true,
});
if (ok) onRollback();
}
const message = useMessage();
const { updateData } = useEventStream();
const { version } = useAppInfo();
@@ -317,41 +332,24 @@ onBeforeUnmount(() => {
{{ checking ? $t('update.checking') : $t('update.check') }}
</ab-button>
<NPopconfirm
:positive-text="$t('update.update_now')"
@positive-click="onApply"
<ab-button
variant="primary"
size="sm"
:disabled="!hasUpdate || applying || restarting"
@click="onApplyClick"
>
<template #trigger>
<ab-button
variant="primary"
size="sm"
:disabled="!hasUpdate || applying || restarting"
>
{{ $t('update.update_now') }}
</ab-button>
</template>
<div class="update-confirm">
<strong>{{ $t('update.confirm_title') }}</strong>
<div>{{ $t('update.confirm_body') }}</div>
</div>
</NPopconfirm>
{{ $t('update.update_now') }}
</ab-button>
<NPopconfirm
<ab-button
v-if="canRollback"
:positive-text="$t('update.rollback')"
@positive-click="onRollback"
variant="danger"
size="sm"
:disabled="applying || restarting"
@click="onRollbackClick"
>
<template #trigger>
<ab-button
variant="danger"
size="sm"
:disabled="applying || restarting"
>
{{ $t('update.rollback') }}
</ab-button>
</template>
{{ $t('update.rollback_confirm') }}
</NPopconfirm>
{{ $t('update.rollback') }}
</ab-button>
</div>
</div>
</ab-container>

View File

@@ -88,9 +88,8 @@ const { t } = useMyI18n();
text-align: left;
}
:deep(.wizard-actions .n-button),
:deep(.test-section .n-button) {
min-height: var(--touch-target);
:deep(.wizard-actions .ab-btn),
:deep(.test-section .ab-btn) {
padding-left: 18px;
padding-right: 18px;
}

View File

@@ -63,11 +63,7 @@ const passwordError = computed(() => {
</div>
<div class="wizard-actions">
<ab-button
size="sm"
variant="secondary"
@click="setupStore.prevStep()"
>
<ab-button size="sm" variant="secondary" @click="setupStore.prevStep()">
{{ t('setup.nav.previous') }}
</ab-button>
<ab-button

View File

@@ -162,11 +162,7 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
</p>
<div class="wizard-actions">
<ab-button
size="sm"
variant="secondary"
@click="setupStore.prevStep()"
>
<ab-button size="sm" variant="secondary" @click="setupStore.prevStep()">
{{ t('setup.nav.previous') }}
</ab-button>
<ab-button

View File

@@ -110,11 +110,7 @@ const canTest = computed(() => {
</div>
<div class="wizard-actions">
<ab-button
size="sm"
variant="secondary"
@click="setupStore.prevStep()"
>
<ab-button size="sm" variant="secondary" @click="setupStore.prevStep()">
{{ t('setup.nav.previous') }}
</ab-button>
<div class="action-group">

View File

@@ -100,11 +100,7 @@ function maskPassword(pwd: string): string {
</div>
<div class="wizard-actions">
<ab-button
size="sm"
variant="secondary"
@click="setupStore.prevStep()"
>
<ab-button size="sm" variant="secondary" @click="setupStore.prevStep()">
{{ t('setup.nav.previous') }}
</ab-button>
<ab-button

View File

@@ -97,11 +97,7 @@ function handleNext() {
</div>
<div class="wizard-actions">
<ab-button
size="sm"
variant="secondary"
@click="setupStore.prevStep()"
>
<ab-button size="sm" variant="secondary" @click="setupStore.prevStep()">
{{ t('setup.nav.previous') }}
</ab-button>
<div class="action-group">

View File

@@ -489,7 +489,9 @@
"clear": "Clear",
"toggle_password": "Show or hide password",
"empty": "Nothing here yet",
"loading": "Loading…"
"loading": "Loading…",
"close": "Close",
"remove": "Remove"
},
"theme": {
"light": "Light",

View File

@@ -489,7 +489,9 @@
"clear": "清空",
"toggle_password": "显示或隐藏密码",
"empty": "暂无内容",
"loading": "加载中…"
"loading": "加载中…",
"close": "关闭",
"remove": "移除"
},
"theme": {
"light": "浅色",

View File

@@ -310,11 +310,7 @@ function groupNeedsReview(group: BangumiGroup): boolean {
:title="rule.subtitle"
type="info"
/>
<ab-tag
v-if="rule.source"
:title="rule.source"
type="info"
/>
<ab-tag v-if="rule.source" :title="rule.source" type="info" />
</div>
<div
v-if="rule.filter && rule.filter.length > 0"

View File

@@ -88,7 +88,16 @@ const sections: ConfigSection[] = [
titleKey: 'config.notification_set.title',
component: ConfigNotification,
groups: ['notification'],
keywords: ['telegram', 'discord', 'bark', 'wecom', 'gotify', 'pushover', 'webhook', 'token'],
keywords: [
'telegram',
'discord',
'bark',
'wecom',
'gotify',
'pushover',
'webhook',
'token',
],
},
{
id: 'proxy',
@@ -371,8 +380,7 @@ onBeforeRouteLeave(() => {
font-size: 13px;
text-align: left;
cursor: pointer;
transition:
background-color var(--transition-fast),
transition: background-color var(--transition-fast),
color var(--transition-fast);
&:hover {
@@ -475,7 +483,7 @@ onBeforeRouteLeave(() => {
width: 100%;
}
.n-button {
:deep(.ab-btn) {
flex: 1;
}
}

View File

@@ -8,7 +8,6 @@ definePage({
name: 'Downloader',
});
const { t } = useMyI18n();
const { config } = storeToRefs(useConfigStore());
const { getConfig } = useConfigStore();
@@ -34,7 +33,6 @@ async function onDeleteSelected() {
if (ok) deleteSelected(false);
}
const isNull = computed(() => {
return config.value.downloader.host === '';
});
@@ -393,7 +391,7 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
@include forMobile {
width: 100%;
:deep(.n-button) {
:deep(.ab-btn) {
flex: 1;
}
}

View File

@@ -162,7 +162,9 @@ onDeactivated(() => {
:key="level"
size="sm"
class="filter-chip"
:variant="selectedLevels.includes(level) ? 'primary' : 'secondary'"
:variant="
selectedLevels.includes(level) ? 'primary' : 'secondary'
"
:aria-pressed="selectedLevels.includes(level)"
@click="toggleLevel(level)"
>
@@ -214,7 +216,10 @@ onDeactivated(() => {
'is-warning': i.type === 'WARNING',
}"
>
<span class="log-level" :class="`level-${i.type.toLowerCase()}`">
<span
class="log-level"
:class="`level-${i.type.toLowerCase()}`"
>
{{ i.type || '—' }}
</span>
<span class="log-date">{{ i.date }}</span>
@@ -311,7 +316,11 @@ onDeactivated(() => {
&.has-errors {
border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
background: color-mix(in srgb, var(--color-danger) 8%, var(--color-surface));
background: color-mix(
in srgb,
var(--color-danger) 8%,
var(--color-surface)
);
color: var(--color-danger);
}
}
@@ -361,12 +370,6 @@ onDeactivated(() => {
display: flex;
gap: 6px;
margin-left: auto;
:deep(.n-button) {
@include forTouch {
min-height: var(--touch-target);
}
}
}
.log-viewer {
@@ -444,7 +447,11 @@ onDeactivated(() => {
}
&.level-error {
background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface));
background: color-mix(
in srgb,
var(--color-danger) 12%,
var(--color-surface)
);
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
color: var(--color-danger);
}

View File

@@ -69,9 +69,7 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
{rss.name}
</span>
{rss.parser && <ab-tag type="info" title={rss.parser} />}
{rss.aggregate && (
<ab-tag type="info" title={t('rss.aggregate')} />
)}
{rss.aggregate && <ab-tag type="info" title={t('rss.aggregate')} />}
</div>
);
},

View File

@@ -362,7 +362,6 @@ async function handleLogin() {
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.passkey-btn {

View File

@@ -62,19 +62,22 @@ export default defineConfig({
[
'bg-theme-row',
{
background: 'linear-gradient(90.5deg, var(--color-primary) 1.53%, var(--color-primary-hover) 96.48%)',
background:
'linear-gradient(90.5deg, var(--color-primary) 1.53%, var(--color-primary-hover) 96.48%)',
},
],
[
'bg-theme-col',
{
background: 'linear-gradient(180deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
background:
'linear-gradient(180deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
},
],
[
'poster-shandow',
{
filter: 'drop-shadow(2px 2px 2px var(--shadow-color, rgba(0, 0, 0, 0.1)))',
filter:
'drop-shadow(2px 2px 2px var(--shadow-color, rgba(0, 0, 0, 0.1)))',
},
],
[