mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-11 14:36:56 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
// 控件在挂载期认领 id,label 在下一个 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');
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>' },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 等原生控件在挂载时认领 controlId;naive 组件无法接收 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -489,7 +489,9 @@
|
||||
"clear": "清空",
|
||||
"toggle_password": "显示或隐藏密码",
|
||||
"empty": "暂无内容",
|
||||
"loading": "加载中…"
|
||||
"loading": "加载中…",
|
||||
"close": "关闭",
|
||||
"remove": "移除"
|
||||
},
|
||||
"theme": {
|
||||
"light": "浅色",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -362,7 +362,6 @@ async function handleLogin() {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
|
||||
}
|
||||
|
||||
.passkey-btn {
|
||||
|
||||
@@ -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)))',
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user