refactor(webui): complete ab-* migration, retire legacy components

Migrate the remaining surfaces: all seven ab-popup call sites to
ab-modal (footer slots, sm sizing), every ab-label to ab-field, all
raw ab-input-shortcut inputs to the ab-input component (textareas get
local Soft Ink styles), rss mobile list to ab-list with loading
skeleton, and de-tint the hand-rolled warning callouts (hairline +
ink, per the no-tinted-boxes rule). Wire aria-live into pull-refresh.

Delete dead code: ab-popup, ab-adaptive-modal, ab-label, ab-data-list,
ab-add, ab-button-multi, ab-image, ab-rule, ab-swipe-container (+
orphaned stories); drop the ab-input/input-error UnoCSS shortcuts.
Add stories for the full universal set and document the final
22-component catalog in DESIGN.md.

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 11:38:59 +02:00
parent ba41059a6b
commit e309301dd6
43 changed files with 383 additions and 1375 deletions

View File

@@ -59,6 +59,19 @@ Semantic scale only: `--z-dropdown` 10 · `--z-sticky` 20 · `--z-fixed` 30 · `
- Soft Ink component grammar: filled controls on `--color-surface-2` (border only on focus/error); tags are hairline chips with a square semantic mark and ink text; status is a square mark + ink label; tabs underline (no thumb); alerts are hairline outlines led by a bold colored word (no icons, no tinted fills); toasts are inverted ink chips; empty states are left-aligned prose with one action; loading is skeletons, not spinners-in-content.
- Every universal component ships a Storybook story and (where it has behavior) a vitest test in `__tests__/`.
The universal set (`src/components/basic/` unless noted):
| Group | Components |
|---|---|
| Actions | `ab-button` (primary/secondary/ghost/danger·sm/md·loading), `ab-icon-button` (required label, 44px touch, ghost/solid/round), `ab-menu` (headlessui dropdown), `ab-split-button` |
| Forms | `ab-field` (label/description/error/required + aria wiring via injection), `ab-input` (affixes, clearable, password reveal, error), `ab-select`, `ab-switch`, `ab-segmented` (underline tabs), `ab-setting` (root — field+control settings row) |
| Overlays | `ab-modal` (adaptive: dialog ≥ tablet, bottom sheet on mobile; footer slot; sizes sm/md/lg), `useConfirm()` + `ab-confirm-host` (promise confirm, danger variant), `ab-tooltip`, `ab-bottom-sheet` (internal to ab-modal) |
| Display | `ab-tag` (semantic marks: success/warning/danger/info/neutral, closable), `ab-status` (running/stopped/paused/degraded + label/detail), `ab-badge` (count/dot, 99+), `ab-container` (root — title/footer/flush card), `ab-fold-panel` (root — accessible, animated), `ab-progress` |
| Data & feedback | `ab-list` (selection, loading skeleton, density comfortable/compact, empty slot), `ab-toolbar` (search/filters/actions), `ab-alert` (hairline, bold lead word), toast via `useMessage()` (inverted ink), `ab-empty`, `ab-skeleton` (lines/row/card), `ab-pull-refresh` |
| Page chrome | `ab-page-title`, `ab-search`, `ab-status-bar` (root), layout shells |
Retired (do not reintroduce): `ab-popup`, `ab-adaptive-modal`, `ab-label`, `ab-setting`'s type-switch-only API, `ab-add`, `ab-button-multi`, `ab-data-list`, `ab-image`, `ab-rule`, `ab-swipe-container`, and the `ab-input` UnoCSS attribute shortcut.
## Voice
UI copy through i18n only (`src/i18n/en.json` + `zh-CN.json`, keys always added in pairs). Sentence case in English. Labels are nouns, buttons are verbs, errors say what to do next.

View File

@@ -7,37 +7,28 @@ const { user, update } = useAuth();
</script>
<template>
<ab-popup
v-model:show="show"
:title="$t('topbar.profile.pop_title')"
css="w-365"
>
<ab-modal v-model:show="show" size="sm" :title="$t('topbar.profile.pop_title')">
<div space-y-16>
<ab-label :label="$t('topbar.profile.username')">
<input
<ab-field :label="$t('topbar.profile.username')">
<ab-input
v-model="user.username"
type="text"
:placeholder="$t('topbar.profile.username')"
ab-input
/>
</ab-label>
</ab-field>
<ab-label :label="$t('topbar.profile.password')">
<input
<ab-field :label="$t('topbar.profile.password')">
<ab-input
v-model="user.password"
type="password"
:placeholder="$t('topbar.profile.password')"
ab-input
/>
</ab-label>
<div line></div>
<div flex="~ justify-end">
<ab-button variant="primary" size="sm" @click="update">{{
$t('topbar.profile.update_btn')
}}</ab-button>
</div>
</ab-field>
</div>
</ab-popup>
<template #footer>
<ab-button variant="primary" size="sm" @click="update">{{
$t('topbar.profile.update_btn')
}}</ab-button>
</template>
</ab-modal>
</template>

View File

@@ -190,11 +190,11 @@ function emitUnarchive() {
<template>
<!-- Enable deleted rule dialog -->
<ab-popup
<ab-modal
v-if="rule.deleted"
v-model:show="show"
size="sm"
:title="$t('homepage.rule.enable_rule')"
css="w-300 max-w-[90vw]"
>
<div>{{ $t('homepage.rule.enable_hit') }}</div>
<div line my-8></div>
@@ -206,7 +206,7 @@ function emitUnarchive() {
{{ $t('homepage.rule.no_btn') }}
</ab-button>
</div>
</ab-popup>
</ab-modal>
<!-- Main edit modal -->
<Teleport v-else to="body">
@@ -314,12 +314,12 @@ function emitUnarchive() {
<label class="weekday-label">{{
$t('homepage.rule.preferred_group')
}}</label>
<input
v-model="localRule.preferred_group"
ab-input
<ab-input
:model-value="localRule.preferred_group ?? ''"
type="text"
class="preferred-input"
placeholder="ANi"
@update:model-value="localRule.preferred_group = String($event)"
/>
</div>

View File

@@ -1,54 +0,0 @@
<script lang="ts" setup>
import { ErrorPicture } from '@icon-park/vue-next';
withDefaults(
defineProps<{
src?: string | null;
aspectRatio?: number;
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
}>(),
{
objectFit: 'cover',
}
);
</script>
<template>
<div rel>
<template v-if="aspectRatio">
<div
w-full
:style="{ paddingBottom: `calc(${1 / aspectRatio} * 100%)` }"
></div>
<img
v-if="src"
:src="src"
alt="poster"
loading="lazy"
abs
top-0
left-0
:style="{ objectFit }"
wh-full
/>
</template>
<template v-else>
<img
v-if="src"
:src="src"
alt="poster"
loading="lazy"
:style="{ objectFit }"
wh-full
/>
<div v-else wh-full f-cer border="1 white">
<ErrorPicture theme="outline" size="24" fill="#333" />
</div>
</template>
</div>
</template>
<style lang="scss" scoped></style>

View File

@@ -1,67 +0,0 @@
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
label: string | (() => string);
/** 关联的表单控件 id提供时渲染真正的 <label for> */
forId?: string;
/** 文本节点 id供 aria-labelledby 引用(无法用 for 关联的组件) */
labelId?: string;
}>(),
{
label: '',
forId: undefined,
labelId: undefined,
}
);
const abLabel = computed(() => {
if (typeof props.label === 'function') return props.label();
else return props.label;
});
</script>
<template>
<div class="label-row">
<label v-if="forId" :id="labelId" class="label-text" :for="forId">
{{ abLabel }}
</label>
<span v-else :id="labelId" class="label-text">{{ abLabel }}</span>
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.label-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-height: 32px;
@include forTablet {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
// Make inputs full-width on mobile
:deep(input[ab-input]),
:deep(.n-select),
:deep(.n-dynamic-tags) {
width: 100%;
@include forTablet {
width: auto;
min-width: 200px;
}
}
}
.label-text {
font-size: 14px;
color: var(--color-text);
transition: color var(--transition-normal);
flex-shrink: 0;
}
</style>

View File

@@ -1,129 +0,0 @@
<script lang="ts" setup>
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue';
const props = withDefaults(
defineProps<{
title: string;
maskClick?: boolean;
css?: string;
}>(),
{
title: 'title',
maskClick: true,
css: '',
}
);
const show = defineModel('show', { default: false });
const { isMobile } = useBreakpointQuery();
function close() {
if (props.maskClick) {
show.value = false;
}
}
</script>
<template>
<!-- Mobile: bottom sheet -->
<ab-bottom-sheet
v-if="isMobile"
:show="show"
:title="title"
:closeable="maskClick"
@update:show="show = $event"
>
<slot></slot>
</ab-bottom-sheet>
<!-- Desktop/Tablet: centered dialog -->
<TransitionRoot v-else appear :show="show" as="template">
<Dialog as="div" class="popup-dialog" @close="close">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="popup-backdrop" />
</TransitionChild>
<div class="popup-wrapper">
<div class="popup-center">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel>
<ab-container :title="title" :class="[css]">
<slot></slot>
</ab-container>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<style lang="scss" scoped>
.popup-dialog {
position: relative;
z-index: var(--z-modal, 50);
}
.popup-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop, 40);
background: rgba(108, 74, 182, 0.15);
backdrop-filter: blur(4px);
}
.popup-wrapper {
position: fixed;
inset: 0;
z-index: var(--z-modal, 50);
overflow-y: auto;
}
.popup-center {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 16px;
text-align: center;
}
:deep(.container-card) {
border: 1px solid var(--color-primary);
box-shadow: 0 8px 32px rgba(108, 74, 182, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08);
border-radius: var(--radius-lg);
overflow: hidden;
}
:deep(.container-header) {
background: var(--color-primary);
color: #fff;
border-bottom: none;
height: 38px;
}
:deep(.container-body) {
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
</style>

View File

@@ -1,138 +0,0 @@
<script lang="ts" setup>
import { NButton } from 'naive-ui';
import type { BangumiRule } from '#/bangumi';
import type { SettingItem } from '#/components';
const { t } = useMyI18n();
const rule = defineModel<BangumiRule>('rule', {
required: true,
});
const offsetLoading = ref(false);
const offsetReason = ref('');
async function autoDetectOffset() {
if (!rule.value.id) return;
offsetLoading.value = true;
offsetReason.value = '';
try {
const result = await apiBangumi.suggestOffset(rule.value.id);
rule.value.episode_offset = result.suggested_offset;
offsetReason.value = result.reason;
} catch (e) {
console.error('Failed to detect offset:', e);
} finally {
offsetLoading.value = false;
}
}
const items: SettingItem<BangumiRule>[] = [
{
configKey: 'official_title',
label: () => t('homepage.rule.official_title'),
type: 'input',
prop: {
type: 'text',
},
},
{
configKey: 'year',
label: () => t('homepage.rule.year'),
type: 'input',
css: 'w-72',
prop: {
type: 'text',
},
},
{
configKey: 'season',
label: () => t('homepage.rule.season'),
type: 'input',
css: 'w-72',
prop: {
type: 'number',
},
},
{
configKey: 'filter',
label: () => t('homepage.rule.exclude'),
type: 'dynamic-tags',
},
];
</script>
<template>
<div class="rule-form">
<ab-setting
v-for="i in items"
:key="i.configKey"
v-bind="i"
v-model:data="rule[i.configKey]"
></ab-setting>
<!-- Offset field with auto-detect button -->
<ab-label :label="() => $t('homepage.rule.offset')">
<div class="offset-controls">
<input
v-model.number="rule.episode_offset"
type="number"
ab-input
class="offset-input"
/>
<NButton
type="primary"
size="small"
:loading="offsetLoading"
@click="autoDetectOffset"
>
{{ $t('homepage.rule.auto_detect') }}
</NButton>
</div>
</ab-label>
<div v-if="offsetReason" class="offset-reason">{{ offsetReason }}</div>
</div>
</template>
<style lang="scss" scoped>
.rule-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.offset-controls {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
@include forTablet {
width: auto;
min-width: 220px;
}
:deep(.ab-button) {
flex-shrink: 0;
white-space: nowrap;
}
}
.offset-input {
width: 80px;
flex-shrink: 0;
@include forMobile {
flex: 1;
min-width: 60px;
}
}
.offset-reason {
font-size: 12px;
color: var(--color-text-secondary);
padding-left: 2px;
margin-top: -8px;
}
</style>

View File

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

View File

@@ -1,45 +0,0 @@
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
title: string;
maskClick?: boolean;
css?: string;
maxHeight?: string;
}>(),
{
title: '',
maskClick: true,
css: '',
maxHeight: '85dvh',
}
);
const show = defineModel('show', { default: false });
const { isMobile } = useBreakpointQuery();
</script>
<template>
<!-- Mobile: Bottom sheet -->
<ab-bottom-sheet
v-if="isMobile"
:show="show"
:title="title"
:closeable="maskClick"
:max-height="maxHeight"
@update:show="show = $event"
>
<slot />
</ab-bottom-sheet>
<!-- Desktop/Tablet: Centered popup -->
<ab-popup
v-else
v-model:show="show"
:title="title"
:mask-click="maskClick"
:css="css"
>
<slot />
</ab-popup>
</template>

View File

@@ -1,22 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbAdd from './ab-add.vue';
const meta: Meta<typeof AbAdd> = {
title: 'basic/ab-add',
component: AbAdd,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbAdd>;
export const Template: Story = {
render: (args) => ({
components: { AbAdd },
setup() {
return { args };
},
template: '<ab-add v-bind="args"></ab-add>',
}),
};

View File

@@ -1,109 +0,0 @@
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
round?: boolean;
type?: 'large' | 'medium' | 'small';
}>(),
{
round: false,
type: 'large',
}
);
defineEmits(['click']);
const buttonSize = computed(() => {
switch (props.type) {
case 'large':
return 'add-btn--large';
case 'medium':
return 'add-btn--medium';
case 'small':
return 'add-btn--small';
}
});
</script>
<template>
<button
class="add-btn"
:class="[buttonSize, round && 'add-btn--round']"
aria-label="Add"
@click="$emit('click')"
>
<svg viewBox="0 0 24 24" fill="none" class="add-btn-icon">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>
</button>
</template>
<style lang="scss" scoped>
.add-btn {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
border: none;
cursor: pointer;
transition: background-color var(--transition-fast),
transform var(--transition-fast);
&:hover {
background: var(--color-primary-hover);
}
&:active {
transform: scale(0.92);
}
&--round {
border-radius: 50%;
}
&--large {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
.add-btn-icon {
width: 18px;
height: 18px;
}
}
&--medium {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
.add-btn-icon {
width: 14px;
height: 14px;
}
}
&--small {
width: 20px;
height: 20px;
border-radius: 4px;
.add-btn-icon {
width: 10px;
height: 10px;
}
}
&--round.add-btn--large {
border-radius: 50%;
}
&--round.add-btn--medium {
border-radius: 50%;
}
&--round.add-btn--small {
border-radius: 50%;
}
}
</style>

View File

@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbAlert from './ab-alert.vue';
const meta: Meta<typeof AbAlert> = {
title: 'basic/ab-alert',
component: AbAlert,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbAlert>;
export const Template: Story = {
render: () => ({
components: { AbAlert },
template: `
<div style="display:flex;flex-direction:column;gap:10px;max-width:520px">
<ab-alert type="info" title="Heads up.">
Version 3.3 moves renaming to the server — no action needed.
</ab-alert>
<ab-alert type="warning" title="Feed unreachable.">
Mikan timed out twice — check the proxy settings.
</ab-alert>
<ab-alert type="danger" title="Parse failed." closable>
The torrent title could not be parsed.
</ab-alert>
</div>`,
}),
};

View File

@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbBadge from './ab-badge.vue';
const meta: Meta<typeof AbBadge> = {
title: 'basic/ab-badge',
component: AbBadge,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbBadge>;
export const Template: Story = {
render: () => ({
components: { AbBadge },
template: `
<div style="display:flex;gap:14px;align-items:center">
<ab-badge :count="3" />
<ab-badge :count="120" />
<ab-badge dot />
<ab-badge :count="0" />
</div>`,
}),
};

View File

@@ -1,32 +0,0 @@
import type {Meta, StoryObj} from '@storybook/vue3';
import AbButtonMulti from './ab-button-multi.vue';
const meta: Meta<typeof AbButtonMulti> = {
title: 'basic/ab-button-multi',
component: AbButtonMulti,
tags: ['autodocs'],
argTypes: {
type: {
control: {type: 'select'},
options: ['primary', 'warn'],
},
size: {
control: {type: 'select'},
options: ['big', 'normal', 'small'],
},
},
};
export default meta;
type Story = StoryObj<typeof AbButtonMulti>;
export const Template: Story = {
render: (args) => ({
components: {AbButtonMulti},
setup() {
return {args};
},
template: '<ab-button-multi v-bind="args">button</ab-button-multi>',
}),
};

View File

@@ -1,193 +0,0 @@
<script lang="ts" setup>
import { NSpin } from 'naive-ui';
import { Down } from '@icon-park/vue-next';
const props = withDefaults(
defineProps<{
type?: 'primary' | 'warn';
size?: 'big' | 'normal' | 'small';
link?: string | null;
loading?: boolean;
selections: string[];
}>(),
{
type: 'primary',
size: 'normal',
link: null,
loading: false,
}
);
defineEmits(['click']);
const selected = ref<string>(props.selections[0]);
const showSelections = ref<boolean>(false);
</script>
<template>
<div class="btn-multi-container">
<div class="btn-multi" :class="[`btn-multi--${size}`, `btn-multi--${type}`]">
<Component
:is="link !== null ? 'a' : 'button'"
:href="link"
class="btn-multi-main"
@click="$emit('click', selected)"
>
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
<div class="btn-multi-label">{{ selected }}</div>
</NSpin>
</Component>
<div
class="btn-multi-arrow"
@click="() => (showSelections = !showSelections)"
>
<Down fill="white" />
</div>
</div>
<div
v-if="showSelections"
class="btn-multi-dropdown"
:class="[`btn-multi--${size}`, `btn-multi--${type}`]"
>
<div
v-for="selection in selections"
:key="selection"
class="btn-multi-option"
@click="() => { selected = selection; showSelections = false; }"
>
{{ selection }}
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.btn-multi-container {
position: relative;
display: inline-flex;
}
.btn-multi {
display: flex;
align-items: center;
overflow: hidden;
color: #fff;
&--big {
border-radius: var(--radius-md);
width: 276px;
height: 44px;
font-size: 16px;
}
&--normal {
border-radius: var(--radius-sm);
min-width: 100px;
height: 36px;
font-size: 14px;
}
&--small {
border-radius: var(--radius-sm);
min-width: 80px;
height: 32px;
font-size: 13px;
}
&--primary {
.btn-multi-main,
.btn-multi-arrow {
background: var(--color-primary);
}
.btn-multi-main:hover,
.btn-multi-arrow:hover {
background: var(--color-primary-hover);
}
}
&--warn {
.btn-multi-main,
.btn-multi-arrow {
background: var(--color-danger);
}
.btn-multi-main:hover,
.btn-multi-arrow:hover {
filter: brightness(0.9);
}
}
}
.btn-multi-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding-left: 12px;
border: none;
outline: none;
color: inherit;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.btn-multi-label {
font-size: inherit;
}
.btn-multi-arrow {
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
height: 100%;
cursor: pointer;
user-select: none;
transition: background-color var(--transition-fast);
}
.btn-multi-dropdown {
position: absolute;
left: 0;
bottom: 100%;
margin-bottom: 4px;
z-index: 100;
overflow: hidden;
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
min-width: 100%;
&.btn-multi--primary {
.btn-multi-option {
background: var(--color-primary);
&:hover {
background: var(--color-primary-hover);
}
}
}
&.btn-multi--warn {
.btn-multi-option {
background: var(--color-danger);
&:hover {
filter: brightness(0.9);
}
}
}
}
.btn-multi-option {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 12px;
cursor: pointer;
user-select: none;
color: #fff;
font-size: inherit;
transition: background-color var(--transition-fast);
white-space: nowrap;
}
</style>

View File

@@ -1,244 +0,0 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
export interface DataListColumn {
key: string;
title: string;
render?: (row: Record<string, unknown>) => string;
hidden?: boolean;
}
type DataItem = Record<string, any>;
const props = withDefaults(
defineProps<{
items: DataItem[];
columns: DataListColumn[];
selectable?: boolean;
keyField?: string;
/** Controlled selection — keeps the checkboxes in sync when the parent
* resets its selection (e.g. after a bulk action). */
selected?: unknown[];
}>(),
{
selectable: false,
keyField: 'id',
selected: undefined,
}
);
const emit = defineEmits<{
(e: 'select', keys: unknown[]): void;
(e: 'action', action: string, item: DataItem): void;
(e: 'item-click', item: DataItem): void;
}>();
const selectedKeys = ref<Set<unknown>>(new Set(props.selected ?? []));
watch(
() => props.selected,
(val) => {
if (val) selectedKeys.value = new Set(val);
},
{ deep: true }
);
const visibleColumns = computed(() =>
props.columns.filter((col) => !col.hidden)
);
function toggleSelect(key: unknown) {
if (selectedKeys.value.has(key)) {
selectedKeys.value.delete(key);
} else {
selectedKeys.value.add(key);
}
emit('select', Array.from(selectedKeys.value));
}
function toggleSelectAll() {
if (selectedKeys.value.size === props.items.length) {
selectedKeys.value.clear();
} else {
props.items.forEach((item) => selectedKeys.value.add(item[props.keyField]));
}
emit('select', Array.from(selectedKeys.value));
}
function getCellValue(item: DataItem, column: DataListColumn): string {
if (column.render) {
return column.render(item);
}
return item[column.key] ?? '';
}
defineExpose({ selectedKeys, toggleSelectAll });
</script>
<template>
<div class="ab-data-list">
<!-- Select all header (when selectable) -->
<div v-if="selectable && items.length > 0" class="ab-data-list__header">
<label class="ab-data-list__select-all">
<input
type="checkbox"
:checked="selectedKeys.size === items.length && items.length > 0"
:indeterminate="
selectedKeys.size > 0 && selectedKeys.size < items.length
"
@change="toggleSelectAll"
/>
<span>{{ $t('common.selectAll') }}</span>
</label>
<span class="ab-data-list__count"
>{{ items.length }} {{ $t('common.items') }}</span
>
</div>
<!-- Items -->
<div
v-for="item in items"
:key="item[keyField]"
class="ab-data-list__item"
@click="emit('item-click', item)"
>
<!-- Checkbox -->
<div v-if="selectable" class="ab-data-list__checkbox" @click.stop>
<input
type="checkbox"
:checked="selectedKeys.has(item[keyField])"
@change="toggleSelect(item[keyField])"
/>
</div>
<!-- Card content -->
<div class="ab-data-list__card">
<slot name="item" :item="item" :columns="visibleColumns">
<!-- Default: key-value pairs -->
<div
v-for="col in visibleColumns"
:key="col.key"
class="ab-data-list__field"
>
<span class="ab-data-list__label">{{ col.title }}</span>
<span class="ab-data-list__value">{{
getCellValue(item, col)
}}</span>
</div>
</slot>
</div>
</div>
<!-- Empty state -->
<div v-if="items.length === 0" class="ab-data-list__empty">
<slot name="empty">
<span>No data</span>
</slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.ab-data-list {
display: flex;
flex-direction: column;
gap: 8px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
&__select-all {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text-secondary);
cursor: pointer;
input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
}
&__count {
font-size: 12px;
color: var(--color-text-muted);
}
&__item {
display: flex;
align-items: stretch;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: border-color var(--transition-fast),
box-shadow var(--transition-fast);
&:active {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
}
&__checkbox {
display: flex;
align-items: center;
padding: 12px;
border-right: 1px solid var(--color-border);
input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
}
&__card {
flex: 1;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
&__field {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
&__label {
font-size: 12px;
color: var(--color-text-muted);
flex-shrink: 0;
}
&__value {
font-size: 14px;
color: var(--color-text);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
color: var(--color-text-muted);
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbEmpty from './ab-empty.vue';
const meta: Meta<typeof AbEmpty> = {
title: 'basic/ab-empty',
component: AbEmpty,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbEmpty>;
export const Template: Story = {
args: {
title: 'No RSS rules yet',
description:
'Add a feed and AutoBangumi starts organizing new episodes automatically.',
},
render: (args) => ({
components: { AbEmpty },
setup() {
return { args };
},
template: '<ab-empty v-bind="args" />',
}),
};

View File

@@ -16,6 +16,7 @@ const props = withDefaults(
min?: number;
max?: number;
step?: number;
maxlength?: number;
}>(),
{
type: 'text',
@@ -27,6 +28,7 @@ const props = withDefaults(
min: undefined,
max: undefined,
step: undefined,
maxlength: undefined,
}
);
@@ -74,6 +76,7 @@ function clear() {
:min="min"
:max="max"
:step="step"
:maxlength="maxlength"
:aria-label="ariaLabel"
:aria-describedby="field?.describedBy.value"
:aria-invalid="invalid || undefined"

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbList from './ab-list.vue';
const meta: Meta<typeof AbList> = {
title: 'basic/ab-list',
component: AbList,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbList>;
export const Template: Story = {
render: () => ({
components: { AbList },
setup() {
const items = [
{ id: 1, name: '葬送的芙莉莲 第二季', meta: 'Mikan · E08' },
{ id: 2, name: 'Re:从零开始的异世界生活', meta: 'Nyaa · E21' },
{ id: 3, name: '怪兽8号', meta: 'DMHY · E12' },
];
return { items };
},
template: `
<ab-list :items="items" selectable :selected="[1]">
<template #row="{ item }">
<div style="display:flex;flex-direction:column;min-width:0">
<b>{{ item.name }}</b>
<small style="color:var(--color-text-secondary)">{{ item.meta }}</small>
</div>
</template>
</ab-list>`,
}),
};

View File

@@ -273,7 +273,7 @@ function handleCancel() {
justify-content: center;
width: 32px;
height: 32px;
background: var(--color-warning-bg);
background: var(--color-surface-2);
color: var(--color-warning);
border-radius: var(--radius-sm);
}
@@ -371,8 +371,8 @@ function handleCancel() {
align-items: flex-start;
gap: 8px;
padding: 12px;
background: var(--color-warning-bg);
border: 1px solid var(--color-warning-border);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: 16px;
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbProgress from './ab-progress.vue';
const meta: Meta<typeof AbProgress> = {
title: 'basic/ab-progress',
component: AbProgress,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbProgress>;
export const Template: Story = {
render: () => ({
components: { AbProgress },
template: `
<div style="display:flex;flex-direction:column;gap:14px;max-width:360px">
<ab-progress :value="72" label="72%" />
<ab-progress :value="31" label="31%" state="error" />
</div>`,
}),
};

View File

@@ -94,7 +94,13 @@ function onTouchEnd() {
@touchcancel="onTouchEnd"
>
<!-- Pull indicator -->
<div class="ab-pull-refresh__indicator" :style="{ opacity: indicatorOpacity }">
<div
class="ab-pull-refresh__indicator"
:style="{ opacity: indicatorOpacity }"
role="status"
aria-live="polite"
:aria-label="loading ? $t('common.loading') : undefined"
>
<div
v-if="loading"
class="ab-pull-refresh__spinner"

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbSkeleton from './ab-skeleton.vue';
const meta: Meta<typeof AbSkeleton> = {
title: 'basic/ab-skeleton',
component: AbSkeleton,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbSkeleton>;
export const Template: Story = {
render: () => ({
components: { AbSkeleton },
template: `
<div style="display:flex;flex-direction:column;gap:24px;max-width:420px">
<ab-skeleton preset="lines" />
<ab-skeleton preset="row" :count="2" />
</div>`,
}),
};

View File

@@ -1,149 +0,0 @@
<script lang="ts" setup>
import { nextTick, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
modelValue?: number;
showDots?: boolean;
itemCount?: number;
}>(),
{
modelValue: 0,
showDots: true,
itemCount: 0,
}
);
const emit = defineEmits<{
(e: 'update:modelValue', index: number): void;
(e: 'change', index: number): void;
}>();
const containerRef = ref<HTMLElement | null>(null);
const currentIndex = ref(props.modelValue);
watch(
() => props.modelValue,
(val) => {
currentIndex.value = val;
scrollToIndex(val);
}
);
function scrollToIndex(index: number) {
const container = containerRef.value;
if (!container) return;
const children = container.children;
if (children[index]) {
(children[index] as HTMLElement).scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'start',
});
}
}
function onScroll() {
const container = containerRef.value;
if (!container) return;
const scrollLeft = container.scrollLeft;
const itemWidth = container.clientWidth;
const newIndex = Math.round(scrollLeft / itemWidth);
if (newIndex !== currentIndex.value) {
currentIndex.value = newIndex;
emit('update:modelValue', newIndex);
emit('change', newIndex);
}
}
function goTo(index: number) {
currentIndex.value = index;
emit('update:modelValue', index);
emit('change', index);
scrollToIndex(index);
}
onMounted(() => {
if (props.modelValue > 0) {
nextTick(() => scrollToIndex(props.modelValue));
}
});
defineExpose({ goTo });
</script>
<template>
<div class="ab-swipe-container">
<div
ref="containerRef"
class="ab-swipe-container__track"
@scroll.passive="onScroll"
>
<slot />
</div>
<!-- Pagination dots -->
<div v-if="showDots && itemCount > 1" class="ab-swipe-container__dots">
<button
v-for="i in itemCount"
:key="i"
class="ab-swipe-container__dot"
:class="{ 'ab-swipe-container__dot--active': currentIndex === i - 1 }"
:aria-label="`Go to slide ${i}`"
@click="goTo(i - 1)"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.ab-swipe-container {
position: relative;
width: 100%;
&__track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
// Hide scrollbar
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
> * {
flex-shrink: 0;
width: 100%;
scroll-snap-align: start;
}
}
&__dots {
display: flex;
justify-content: center;
gap: 6px;
padding: 12px 0;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: var(--color-border);
cursor: pointer;
padding: 0;
transition: background var(--transition-fast), transform var(--transition-fast);
&--active {
background: var(--color-primary);
transform: scale(1.25);
}
}
}
</style>

View File

@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbToolbar from './ab-toolbar.vue';
const meta: Meta<typeof AbToolbar> = {
title: 'basic/ab-toolbar',
component: AbToolbar,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbToolbar>;
export const Template: Story = {
render: () => ({
components: { AbToolbar },
template: `
<ab-toolbar>
<template #search>
<input style="width:100%;height:32px" placeholder="Search…" />
</template>
<template #actions>
<button>Add RSS</button>
</template>
</ab-toolbar>`,
}),
};

View File

@@ -105,9 +105,9 @@ function relativeTime(iso: string): string {
{{ $t('notifications.mark_all_read') }}
</ab-button>
<ab-button
v-if="messages.length > 0"
variant="danger"
size="sm"
v-if="messages.length > 0"
class="notification-action"
@click="onClearClick"
>

View File

@@ -268,10 +268,9 @@ function handleConfirm() {
$t('homepage.rule.season_offset')
}}</label>
<div class="advanced-control offset-controls">
<input
v-model.number="localBangumi.season_offset"
<ab-input
v-model="localBangumi.season_offset"
type="number"
ab-input
class="offset-input"
/>
</div>
@@ -283,10 +282,9 @@ function handleConfirm() {
$t('homepage.rule.episode_offset')
}}</label>
<div class="advanced-control offset-controls">
<input
v-model.number="localBangumi.episode_offset"
<ab-input
v-model="localBangumi.episode_offset"
type="number"
ab-input
class="offset-input"
/>
<button

View File

@@ -251,14 +251,14 @@ const tuningItems = computed(() => [
<transition name="slide-fade">
<div v-if="llm.enable" class="llm-config">
<ab-label :label="() => t('config.llm_set.provider')">
<ab-field :label="() => t('config.llm_set.provider')">
<NSelect
v-model:value="llm.provider"
class="model-select"
:options="providerOptions"
:aria-label="t('config.llm_set.provider')"
/>
</ab-label>
</ab-field>
<ab-setting
v-model:data="llm.mode"
@@ -298,7 +298,7 @@ const tuningItems = computed(() => [
:prop="{ type: 'password', placeholder: 'sk-...' }"
/>
<ab-label :label="() => t('config.llm_set.model')">
<ab-field :label="() => t('config.llm_set.model')">
<NSelect
v-model:value="model"
class="model-select"
@@ -310,7 +310,7 @@ const tuningItems = computed(() => [
:aria-label="t('config.llm_set.model')"
@update:show="onModelDropdownShow"
/>
</ab-label>
</ab-field>
<ab-setting
v-if="selected?.needs_base_url"
@@ -462,13 +462,13 @@ const tuningItems = computed(() => [
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--color-warning-bg, #fef9ed);
border: 1px solid var(--color-warning-border, #fde68a);
background: transparent;
border: 1px solid var(--color-border);
}
.llm-risk-text {
font-size: 12.5px;
color: var(--color-warning-text, #92400e);
color: var(--color-text);
}
.llm-risk-actions {

View File

@@ -343,40 +343,38 @@ function getFieldsForType(type: string) {
</div>
<!-- Add Dialog -->
<ab-popup
<ab-modal
v-model:show="showAddDialog"
size="sm"
:title="$t('config.notification_set.add_provider')"
css="w-400"
>
<div space-y-16>
<ab-label :label="$t('config.notification_set.type')">
<ab-field :label="$t('config.notification_set.type')">
<NSelect
v-model:value="newProvider.type"
:options="providerTypes"
class="provider-type-select"
/>
</ab-label>
</ab-field>
<ab-label
<ab-field
v-for="field in getFieldsForType(newProvider.type)"
:key="field.key"
:label="field.label"
>
<input
<ab-input
v-if="field.key !== 'template'"
v-model="(newProvider as any)[field.key]"
:placeholder="field.placeholder"
ab-input
/>
<textarea
v-else
v-model="(newProvider as any)[field.key]"
:placeholder="field.placeholder"
ab-input
class="field-textarea"
rows="3"
/>
</ab-label>
</ab-field>
<div
v-if="testResult"
@@ -413,44 +411,42 @@ function getFieldsForType(type: string) {
</div>
</div>
</div>
</ab-popup>
</ab-modal>
<!-- Edit Dialog -->
<ab-popup
<ab-modal
v-model:show="showEditDialog"
size="sm"
:title="$t('config.notification_set.edit_provider')"
css="w-400"
>
<div space-y-16>
<ab-label :label="$t('config.notification_set.type')">
<ab-field :label="$t('config.notification_set.type')">
<NSelect
v-model:value="newProvider.type"
:options="providerTypes"
disabled
class="provider-type-select"
/>
</ab-label>
</ab-field>
<ab-label
<ab-field
v-for="field in getFieldsForType(newProvider.type)"
:key="field.key"
:label="field.label"
>
<input
<ab-input
v-if="field.key !== 'template'"
v-model="(newProvider as any)[field.key]"
:placeholder="field.placeholder"
ab-input
/>
<textarea
v-else
v-model="(newProvider as any)[field.key]"
:placeholder="field.placeholder"
ab-input
class="field-textarea"
rows="3"
/>
</ab-label>
</ab-field>
<div
v-if="testResult"
@@ -487,7 +483,7 @@ function getFieldsForType(type: string) {
</div>
</div>
</div>
</ab-popup>
</ab-modal>
</ab-fold-panel>
</template>
@@ -565,10 +561,29 @@ function getFieldsForType(type: string) {
}
.field-textarea {
// Soft Ink 填充式多行输入ab-input 组件暂不支持 textarea
width: 100%;
padding: 8px 11px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text);
outline: none;
resize: none;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
monospace;
font-family: var(--font-mono);
font-size: 13px;
transition: border-color var(--transition-fast),
background-color var(--transition-fast), box-shadow var(--transition-fast);
&:focus {
background: var(--color-surface);
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
@include forTablet {
width: 220px;
}
}
.test-result {

View File

@@ -146,22 +146,20 @@ function formatDate(dateString: string | null): string {
</div>
<!-- 添加对话框 -->
<ab-popup
<ab-modal
v-model:show="showAddDialog"
size="sm"
:title="$t('passkey.register_title')"
css="w-365"
>
<div space-y-16>
<ab-label :label="$t('passkey.device_name')">
<input
<ab-field :label="$t('passkey.device_name')">
<ab-input
v-model="deviceName"
type="text"
:placeholder="$t('passkey.device_name_placeholder')"
ab-input
maxlength="64"
:maxlength="64"
@keyup.enter="handleAdd"
/>
</ab-label>
</ab-field>
<div text-14 text-gray-500>
{{ $t('passkey.register_hint') }}
@@ -183,6 +181,6 @@ function formatDate(dateString: string | null): string {
</ab-button>
</div>
</div>
</ab-popup>
</ab-modal>
</ab-fold-panel>
</template>

View File

@@ -218,31 +218,28 @@ function validateUrl(url: string): boolean {
</div>
<!-- Add dialog -->
<ab-popup
<ab-modal
v-model:show="showAddDialog"
size="sm"
:title="$t('config.search_provider_set.add_title')"
css="w-400"
>
<div space-y-16>
<ab-label :label="$t('config.search_provider_set.name')">
<input
<ab-field :label="$t('config.search_provider_set.name')">
<ab-input
v-model="formName"
type="text"
:placeholder="$t('config.search_provider_set.name_placeholder')"
ab-input
maxlength="32"
:maxlength="32"
/>
</ab-label>
</ab-field>
<ab-label :label="$t('config.search_provider_set.url')">
<input
<ab-field :label="$t('config.search_provider_set.url')">
<ab-input
v-model="formUrl"
type="text"
:placeholder="$t('config.search_provider_set.url_placeholder')"
ab-input
@keyup.enter="handleAdd"
/>
</ab-label>
</ab-field>
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
{{ $t('config.search_provider_set.url_missing_placeholder') }}
@@ -266,38 +263,35 @@ function validateUrl(url: string): boolean {
</ab-button>
</div>
</div>
</ab-popup>
</ab-modal>
<!-- Edit dialog -->
<ab-popup
<ab-modal
v-model:show="showEditDialog"
size="sm"
:title="$t('config.search_provider_set.edit_title')"
css="w-400"
>
<div space-y-16>
<ab-label :label="$t('config.search_provider_set.name')">
<input
<ab-field :label="$t('config.search_provider_set.name')">
<ab-input
v-model="formName"
type="text"
:placeholder="$t('config.search_provider_set.name_placeholder')"
ab-input
maxlength="32"
:maxlength="32"
:disabled="
editingProvider !== null &&
isDefaultProvider(editingProvider.name)
"
/>
</ab-label>
</ab-field>
<ab-label :label="$t('config.search_provider_set.url')">
<input
<ab-field :label="$t('config.search_provider_set.url')">
<ab-input
v-model="formUrl"
type="text"
:placeholder="$t('config.search_provider_set.url_placeholder')"
ab-input
@keyup.enter="handleEdit"
/>
</ab-label>
</ab-field>
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
{{ $t('config.search_provider_set.url_missing_placeholder') }}
@@ -321,7 +315,7 @@ function validateUrl(url: string): boolean {
</ab-button>
</div>
</div>
</ab-popup>
</ab-modal>
</ab-fold-panel>
</template>

View File

@@ -34,30 +34,30 @@ const passwordError = computed(() => {
<p class="step-subtitle">{{ t('setup.account.subtitle') }}</p>
<div class="form-fields">
<ab-label :label="t('setup.account.username')">
<ab-field :label="t('setup.account.username')">
<input
v-model="accountData.username"
type="text"
placeholder="admin"
class="setup-input"
/>
</ab-label>
</ab-field>
<ab-label :label="t('setup.account.password')">
<ab-field :label="t('setup.account.password')">
<input
v-model="accountData.password"
type="password"
class="setup-input"
/>
</ab-label>
</ab-field>
<ab-label :label="t('setup.account.confirm_password')">
<ab-field :label="t('setup.account.confirm_password')">
<input
v-model="accountData.confirmPassword"
type="password"
class="setup-input"
/>
</ab-label>
</ab-field>
<p v-if="passwordError" class="error-text">{{ passwordError }}</p>
</div>

View File

@@ -82,57 +82,57 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
<p class="step-subtitle">{{ t('setup.downloader.subtitle') }}</p>
<div class="form-fields">
<ab-label :label="t('config.downloader_set.type')">
<ab-field :label="t('config.downloader_set.type')">
<NSelect
v-model:value="downloaderData.type"
:options="downloaderTypeOptions"
:aria-label="t('config.downloader_set.type')"
class="type-select"
/>
</ab-label>
</ab-field>
<p v-if="downloaderData.type === 'aria2'" class="aria2-hint">
{{ t('config.downloader_set.aria2_hint') }}
</p>
<ab-label :label="t('config.downloader_set.host')">
<ab-field :label="t('config.downloader_set.host')">
<input
v-model="downloaderData.host"
type="text"
placeholder="172.17.0.1:8080"
class="setup-input"
/>
</ab-label>
</ab-field>
<ab-label :label="t('config.downloader_set.username')">
<ab-field :label="t('config.downloader_set.username')">
<input
v-model="downloaderData.username"
type="text"
placeholder="admin"
class="setup-input"
/>
</ab-label>
</ab-field>
<ab-label :label="t('config.downloader_set.password')">
<ab-field :label="t('config.downloader_set.password')">
<input
v-model="downloaderData.password"
type="password"
class="setup-input"
/>
</ab-label>
</ab-field>
<ab-label :label="t('config.downloader_set.path')">
<ab-field :label="t('config.downloader_set.path')">
<input
v-model="downloaderData.path"
type="text"
placeholder="/downloads/Bangumi"
class="setup-input"
/>
</ab-label>
</ab-field>
<ab-label :label="t('config.downloader_set.ssl')">
<ab-field :label="t('config.downloader_set.ssl')">
<NSwitch v-model:value="downloaderData.ssl" />
</ab-label>
</ab-field>
</div>
<div class="test-section">

View File

@@ -62,29 +62,29 @@ const canTest = computed(() => {
<p class="step-subtitle">{{ t('setup.notification.subtitle') }}</p>
<div class="form-fields">
<ab-label :label="t('config.notification_set.type')">
<ab-field :label="t('config.notification_set.type')">
<NSelect
v-model:value="notificationData.type"
:options="notificationTypes"
size="small"
/>
</ab-label>
</ab-field>
<ab-label :label="t('config.notification_set.token')">
<ab-field :label="t('config.notification_set.token')">
<input
v-model="notificationData.token"
type="text"
class="setup-input setup-input-wide"
/>
</ab-label>
</ab-field>
<ab-label :label="t('config.notification_set.chat_id')">
<ab-field :label="t('config.notification_set.chat_id')">
<input
v-model="notificationData.chat_id"
type="text"
class="setup-input"
/>
</ab-label>
</ab-field>
</div>
<div class="test-section">

View File

@@ -55,18 +55,18 @@ function handleNext() {
<p class="step-subtitle">{{ t('setup.rss.subtitle') }}</p>
<div class="form-fields">
<ab-label :label="t('setup.rss.url')">
<ab-field :label="t('setup.rss.url')">
<input
v-model="rssData.url"
type="text"
placeholder="https://mikanani.me/RSS/..."
class="setup-input setup-input-wide"
/>
</ab-label>
</ab-field>
<ab-label v-if="rssData.name" :label="t('setup.rss.feed_name')">
<ab-field v-if="rssData.name" :label="t('setup.rss.feed_name')">
<input v-model="rssData.name" type="text" class="setup-input" />
</ab-label>
</ab-field>
</div>
<div class="test-section">

View File

@@ -488,7 +488,8 @@
"copy": "Copy",
"clear": "Clear",
"toggle_password": "Show or hide password",
"empty": "Nothing here yet"
"empty": "Nothing here yet",
"loading": "Loading…"
},
"theme": {
"light": "Light",

View File

@@ -488,7 +488,8 @@
"copy": "复制",
"clear": "清空",
"toggle_password": "显示或隐藏密码",
"empty": "暂无内容"
"empty": "暂无内容",
"loading": "加载中…"
},
"theme": {
"light": "浅色",

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useConfirm } from '@/hooks/useConfirm';
import type { Component } from 'vue';
import { useConfirm } from '@/hooks/useConfirm';
import type { Config } from '#/config';
import ConfigNormal from '@/components/setting/config-normal.vue';
import ConfigParser from '@/components/setting/config-parser.vue';
@@ -251,9 +251,8 @@ onBeforeRouteLeave(() => {
class="config-rail"
:aria-label="$t('config.search_placeholder')"
>
<input
<ab-input
v-model="searchQuery"
ab-input
class="rail-search"
type="search"
:placeholder="$t('config.search_placeholder')"

View File

@@ -8,17 +8,6 @@ definePage({
name: 'Downloader',
});
const { confirm } = useConfirm();
async function onDeleteSelected() {
const ok = await confirm({
title: t('downloader.action.delete'),
body: t('downloader.action.delete_confirm'),
confirmText: t('downloader.action.delete'),
danger: true,
});
if (ok) deleteSelected(false);
}
const { t } = useMyI18n();
const { config } = storeToRefs(useConfigStore());
@@ -33,6 +22,18 @@ const {
toggleGroup,
clearSelection,
} = useDownloaderStore();
const { confirm } = useConfirm();
async function onDeleteSelected() {
const ok = await confirm({
title: t('downloader.action.delete'),
body: t('downloader.action.delete_confirm'),
confirmText: t('downloader.action.delete'),
danger: true,
});
if (ok) deleteSelected(false);
}
const isNull = computed(() => {
return config.value.downloader.host === '';

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useConfirm } from '@/hooks/useConfirm';
import { watchOnce } from '@vueuse/core';
import { useConfirm } from '@/hooks/useConfirm';
import { countLogLevels, parseLogLines } from '@/utils/log-parse';
import type { LogLevel } from '@/utils/log-parse';
@@ -171,9 +171,8 @@ onDeactivated(() => {
</ab-button>
</div>
<input
<ab-input
v-model="searchQuery"
ab-input
class="log-search"
type="search"
:placeholder="$t('log.search_placeholder')"

View File

@@ -11,15 +11,6 @@ definePage({
const { t } = useMyI18n();
const { confirm } = useConfirm();
async function onDeleteSelected() {
const ok = await confirm({
title: t('rss.delete'),
body: t('rss.delete_confirm'),
confirmText: t('rss.delete'),
danger: true,
});
if (ok) deleteSelected();
}
const { isMobile } = useBreakpointQuery();
const { rss, selectedRSS, isRefreshingAll, isLoading } = storeToRefs(
useRSSStore()
@@ -33,6 +24,16 @@ const {
refreshAllRSS,
} = useRSSStore();
async function onDeleteSelected() {
const ok = await confirm({
title: t('rss.delete'),
body: t('rss.delete_confirm'),
confirmText: t('rss.delete'),
danger: true,
});
if (ok) deleteSelected();
}
onActivated(() => {
getAll();
});
@@ -167,19 +168,16 @@ const rssRowKey = (row: RSS) => row.id;
</template>
<!-- Mobile: Card-based list -->
<ab-data-list
<ab-list
v-if="isMobile"
:items="rss || []"
:columns="[
{ key: 'name', title: t('rss.name') },
{ key: 'url', title: t('rss.url') },
]"
:selectable="true"
selectable
key-field="id"
:loading="isLoading && rss.length === 0"
:selected="selectedRSS"
@select="(keys) => (selectedRSS = keys as number[])"
@update:selected="(keys) => (selectedRSS = keys as number[])"
>
<template #item="{ item }">
<template #row="{ item }">
<div class="rss-card-content">
<div class="rss-card-name">{{ item.name }}</div>
<div class="rss-card-url">{{ item.url }}</div>
@@ -198,7 +196,11 @@ const rssRowKey = (row: RSS) => row.id;
/>
<ab-tag
:type="item.enabled ? 'success' : 'neutral'"
:title="item.enabled ? 'success' : 'neutral'"
:title="
item.enabled
? $t('config.notification_set.enabled')
: $t('config.notification_set.disabled')
"
/>
</div>
<!-- Inline on touch a tooltip can't be hovered on a phone -->
@@ -228,7 +230,7 @@ const rssRowKey = (row: RSS) => row.id;
</div>
</div>
</template>
</ab-data-list>
</ab-list>
<!-- Desktop: Data table -->
<NDataTable

View File

@@ -9,27 +9,21 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
AbAdaptiveModal: typeof import('./../../src/components/basic/ab-adaptive-modal.vue')['default']
AbAdd: typeof import('./../../src/components/basic/ab-add.vue')['default']
AbAddRss: typeof import('./../../src/components/ab-add-rss.vue')['default']
AbAlert: typeof import('./../../src/components/basic/ab-alert.vue')['default']
AbBadge: typeof import('./../../src/components/basic/ab-badge.vue')['default']
AbBangumiCard: typeof import('./../../src/components/ab-bangumi-card.vue')['default']
AbBottomSheet: typeof import('./../../src/components/basic/ab-bottom-sheet.vue')['default']
AbButton: typeof import('./../../src/components/basic/ab-button.vue')['default']
AbButtonMulti: typeof import('./../../src/components/basic/ab-button-multi.vue')['default']
AbChangeAccount: typeof import('./../../src/components/ab-change-account.vue')['default']
AbConfirmHost: typeof import('./../../src/components/basic/ab-confirm-host.vue')['default']
AbContainer: typeof import('./../../src/components/ab-container.vue')['default']
AbDataList: typeof import('./../../src/components/basic/ab-data-list.vue')['default']
AbEditRule: typeof import('./../../src/components/ab-edit-rule.vue')['default']
AbEmpty: typeof import('./../../src/components/basic/ab-empty.vue')['default']
AbField: typeof import('./../../src/components/basic/ab-field.vue')['default']
AbFoldPanel: typeof import('./../../src/components/ab-fold-panel.vue')['default']
AbIconButton: typeof import('./../../src/components/basic/ab-icon-button.vue')['default']
AbImage: typeof import('./../../src/components/ab-image.vue')['default']
AbInput: typeof import('./../../src/components/basic/ab-input.vue')['default']
AbLabel: typeof import('./../../src/components/ab-label.vue')['default']
AbList: typeof import('./../../src/components/basic/ab-list.vue')['default']
AbMenu: typeof import('./../../src/components/basic/ab-menu.vue')['default']
AbMobileNav: typeof import('./../../src/components/layout/ab-mobile-nav.vue')['default']
@@ -37,10 +31,8 @@ declare module '@vue/runtime-core' {
AbNotificationCenter: typeof import('./../../src/components/layout/ab-notification-center.vue')['default']
AbOffsetMismatchDialog: typeof import('./../../src/components/basic/ab-offset-mismatch-dialog.vue')['default']
AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']
AbPopup: typeof import('./../../src/components/ab-popup.vue')['default']
AbProgress: typeof import('./../../src/components/basic/ab-progress.vue')['default']
AbPullRefresh: typeof import('./../../src/components/basic/ab-pull-refresh.vue')['default']
AbRule: typeof import('./../../src/components/ab-rule.vue')['default']
AbSearch: typeof import('./../../src/components/basic/ab-search.vue')['default']
AbSearchBar: typeof import('./../../src/components/ab-search-bar.vue')['default']
AbSearchCard: typeof import('./../../src/components/search/ab-search-card.vue')['default']
@@ -55,7 +47,6 @@ declare module '@vue/runtime-core' {
AbSplitButton: typeof import('./../../src/components/basic/ab-split-button.vue')['default']
AbStatus: typeof import('./../../src/components/basic/ab-status.vue')['default']
AbStatusBar: typeof import('./../../src/components/ab-status-bar.vue')['default']
AbSwipeContainer: typeof import('./../../src/components/basic/ab-swipe-container.vue')['default']
AbSwitch: typeof import('./../../src/components/basic/ab-switch.vue')['default']
AbTag: typeof import('./../../src/components/basic/ab-tag.vue')['default']
AbToolbar: typeof import('./../../src/components/basic/ab-toolbar.vue')['default']

View File

@@ -118,17 +118,6 @@ export default defineConfig({
// input
{
// Soft Ink填充式控件无边框边框只在聚焦primary/错误danger时出现
'ab-input': `outline-none min-w-0 w-full sm:w-200 h-36 sm:h-28
px-12 text-main text-left
rounded-4
border-1 border-transparent
bg-surface-2 text-text-primary
focus:border-primary focus:ring-2 focus:ring-primary/20
transition-colors duration-150
`,
'input-error': 'border-danger',
'input-reset': 'bg-transparent min-w-0 flex-1 outline-none',
},