mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-06 07:29:09 +08:00
feat(webui): Soft Ink universal component set
Overlays: ab-modal (adaptive dialog/sheet on headlessui, focus trap, footer slot), promise-based useConfirm + host, ab-tooltip. Forms: ab-field (label/description/error/aria wiring), ab-input component (affixes, clearable, password reveal), ab-select/ab-switch wrappers, ab-segmented underline tabs; ab-setting rebuilt on them with new description/error/required props. Display: ab-tag v2 (semantic marks, ink text, no truncation), ab-status v2 (4 states + label/detail), ab-badge, ab-progress (scaleX fill), ab-container footer slot, accessible animated ab-fold-panel. Feedback: ab-alert (hairline, bold lead word), inverted-ink toast theme, ab-empty, ab-skeleton, ab-list (selection, loading, density), ab-toolbar. All with vitest coverage and Storybook stories; bottom sheet gains footer slot and drops hard-coded overlay/z-index. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
@@ -147,6 +147,7 @@ const naiveTheme = computed(() => (isDark.value ? darkTheme : null));
|
||||
<NMessageProvider>
|
||||
<NDialogProvider>
|
||||
<RouterView></RouterView>
|
||||
<ab-confirm-host />
|
||||
</NDialogProvider>
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
/** 去掉 body 内边距(内容自带布局时,如列表/表格贴边) */
|
||||
flush?: boolean;
|
||||
}>(),
|
||||
{
|
||||
title: 'title',
|
||||
flush: false,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -16,9 +19,13 @@ withDefaults(
|
||||
<slot name="title-right"></slot>
|
||||
</div>
|
||||
|
||||
<div class="container-body">
|
||||
<div class="container-body" :class="{ 'container-body--flush': flush }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" class="container-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,5 +64,18 @@ withDefaults(
|
||||
font-size: 14px;
|
||||
transition: background-color var(--transition-normal),
|
||||
color var(--transition-normal);
|
||||
|
||||
&--flush {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.container-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Down, Up } from '@icon-park/vue-next';
|
||||
import { Down } from '@icon-park/vue-next';
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
|
||||
|
||||
// 折叠面板 v2:headlessui 提供键盘/aria-expanded;
|
||||
// 旋转箭头 + grid-rows 高度动画(reduced-motion 下直接切换)。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -17,16 +19,28 @@ withDefaults(
|
||||
<template>
|
||||
<Disclosure v-slot="{ open }" :default-open="defaultOpen">
|
||||
<div class="fold-panel">
|
||||
<DisclosureButton class="fold-panel-header">
|
||||
<DisclosureButton
|
||||
class="fold-panel-header"
|
||||
:class="{ 'fold-panel-header--open': open }"
|
||||
>
|
||||
<div class="fold-panel-title">{{ title }}</div>
|
||||
<Component :is="open ? Up : Down" :size="14" />
|
||||
<slot name="extra"></slot>
|
||||
<Down
|
||||
class="fold-panel-chevron"
|
||||
:class="{ 'fold-panel-chevron--open': open }"
|
||||
:size="14"
|
||||
/>
|
||||
</DisclosureButton>
|
||||
|
||||
<DisclosurePanel>
|
||||
<div class="fold-panel-body">
|
||||
<slot></slot>
|
||||
<div class="fold-panel-collapse" :class="{ 'is-open': open }">
|
||||
<div class="fold-panel-clip">
|
||||
<DisclosurePanel static>
|
||||
<div class="fold-panel-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure>
|
||||
</template>
|
||||
@@ -38,33 +52,80 @@ withDefaults(
|
||||
transition: border-color var(--transition-normal);
|
||||
min-width: 0; // Allow panel to shrink below content size
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fold-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 14px;
|
||||
height: 34px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
|
||||
@include forTouch {
|
||||
height: var(--touch-target);
|
||||
}
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
&--open {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.fold-panel-title {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fold-panel-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-normal);
|
||||
|
||||
&--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// grid-rows 高度动画:0fr ↔ 1fr(不动画 height 本身)
|
||||
.fold-panel-collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
visibility: hidden;
|
||||
transition: grid-template-rows var(--transition-normal),
|
||||
visibility 0s var(--transition-normal);
|
||||
|
||||
&.is-open {
|
||||
grid-template-rows: 1fr;
|
||||
visibility: visible;
|
||||
transition: grid-template-rows var(--transition-normal), visibility 0s;
|
||||
}
|
||||
}
|
||||
|
||||
.fold-panel-clip {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fold-panel-body {
|
||||
background: var(--color-surface);
|
||||
padding: 12px 14px;
|
||||
@@ -74,4 +135,11 @@ withDefaults(
|
||||
transition: background-color var(--transition-normal),
|
||||
color var(--transition-normal);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fold-panel-collapse,
|
||||
.fold-panel-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import { useId } from 'vue';
|
||||
import { NDynamicTags, NSelect, NSwitch } from 'naive-ui';
|
||||
import { NDynamicTags } from 'naive-ui';
|
||||
import { isObject } from 'radash';
|
||||
import type { SelectOption } from 'naive-ui';
|
||||
import AbField from './basic/ab-field.vue';
|
||||
import AbInput from './basic/ab-input.vue';
|
||||
import AbSelect from './basic/ab-select.vue';
|
||||
import AbSwitch from './basic/ab-switch.vue';
|
||||
import type { AbSettingProps, SelectItem } from '#/components';
|
||||
|
||||
// 设置行:ab-field(标签/说明/错误/aria)+ 对应控件的组合。
|
||||
const props = withDefaults(defineProps<AbSettingProps>(), {
|
||||
css: '',
|
||||
bottomLine: false,
|
||||
description: '',
|
||||
error: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
|
||||
const data = defineModel<any>('data');
|
||||
|
||||
// 每个设置项一对稳定 id:原生 input 用 <label for>,
|
||||
// naive-ui 组件(无法透传 id 到焦点元素)用 aria-label/aria-labelledby
|
||||
const controlId = useId();
|
||||
const labelId = useId();
|
||||
|
||||
const labelText = computed(() =>
|
||||
typeof props.label === 'function' ? props.label() : props.label
|
||||
);
|
||||
|
||||
// 旧 AbSelect 接受 items: Array<SelectItem | string>;NSelect 需要
|
||||
// options: Array<{ label, value }>,这里做一层适配
|
||||
const selectOptions = computed<SelectOption[]>(() => {
|
||||
const items: Array<SelectItem | string> = props.prop?.items ?? [];
|
||||
return items.map((item) =>
|
||||
isObject(item)
|
||||
? { label: item.label ?? item.value, value: item.value }
|
||||
: { label: item, value: item }
|
||||
);
|
||||
});
|
||||
|
||||
// 旧组件允许 v-model 绑定对象(SelectItem);NSelect 只接受基础类型,
|
||||
// 读取时取 .value,写回时保持字符串(配置值均为字符串)
|
||||
// 旧组件允许 v-model 绑定对象(SelectItem);读取时取 .value,
|
||||
// 写回时保持字符串(配置值均为字符串)
|
||||
const selectValue = computed<string | number | null>({
|
||||
get() {
|
||||
return isObject(data.value) ? (data.value as SelectItem).value : data.value;
|
||||
@@ -47,36 +36,37 @@ const selectValue = computed<string | number | null>({
|
||||
|
||||
<template>
|
||||
<div class="setting-item">
|
||||
<ab-label
|
||||
<AbField
|
||||
:label="label"
|
||||
:for-id="type === 'input' ? controlId : undefined"
|
||||
:label-id="labelId"
|
||||
:description="description"
|
||||
:error="error"
|
||||
:required="required"
|
||||
>
|
||||
<NSwitch
|
||||
<AbSwitch
|
||||
v-if="type === 'switch'"
|
||||
v-model:value="data"
|
||||
v-model="data"
|
||||
:class="css"
|
||||
:aria-label="labelText"
|
||||
:aria-labelledby="labelId"
|
||||
></NSwitch>
|
||||
></AbSwitch>
|
||||
|
||||
<NSelect
|
||||
<AbSelect
|
||||
v-else-if="type === 'select'"
|
||||
v-model:value="selectValue"
|
||||
:options="selectOptions"
|
||||
v-model="selectValue"
|
||||
:items="prop?.items ?? []"
|
||||
:class="css"
|
||||
class="setting-select"
|
||||
:aria-label="labelText"
|
||||
></NSelect>
|
||||
></AbSelect>
|
||||
|
||||
<input
|
||||
<AbInput
|
||||
v-else-if="type === 'input'"
|
||||
:id="controlId"
|
||||
v-model="data"
|
||||
ab-input
|
||||
:class="css"
|
||||
v-bind="prop"
|
||||
/>
|
||||
:type="prop?.type"
|
||||
:placeholder="prop?.placeholder"
|
||||
:min="prop?.min"
|
||||
:max="prop?.max"
|
||||
></AbInput>
|
||||
|
||||
<div v-else-if="type === 'dynamic-tags'" class="dynamic-tags-wrapper">
|
||||
<NDynamicTags
|
||||
@@ -85,7 +75,7 @@ const selectValue = computed<string | number | null>({
|
||||
:aria-label="labelText"
|
||||
></NDynamicTags>
|
||||
</div>
|
||||
</ab-label>
|
||||
</AbField>
|
||||
|
||||
<div v-if="bottomLine" class="setting-divider"></div>
|
||||
</div>
|
||||
@@ -104,7 +94,8 @@ const selectValue = computed<string | number | null>({
|
||||
:deep(input),
|
||||
:deep(select),
|
||||
:deep(.n-select),
|
||||
:deep(.n-input) {
|
||||
:deep(.n-input),
|
||||
:deep(.ab-input) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Down, Right } from '@icon-park/vue-next';
|
||||
import { Down } from '@icon-park/vue-next';
|
||||
|
||||
/** v-model open state */
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
@@ -7,8 +7,18 @@ const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
<template>
|
||||
<div class="advanced-section">
|
||||
<button class="advanced-toggle" @click="open = !open">
|
||||
<component :is="open ? Down : Right" theme="outline" size="14" />
|
||||
<button
|
||||
type="button"
|
||||
class="advanced-toggle"
|
||||
:aria-expanded="open"
|
||||
@click="open = !open"
|
||||
>
|
||||
<Down
|
||||
class="advanced-chevron"
|
||||
:class="{ 'advanced-chevron--open': open }"
|
||||
theme="outline"
|
||||
size="14"
|
||||
/>
|
||||
{{ $t('search.confirm.advanced') }}
|
||||
</button>
|
||||
|
||||
@@ -42,6 +52,25 @@ const open = defineModel<boolean>('open', { default: false });
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-chevron {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform var(--transition-normal);
|
||||
|
||||
&--open {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-content {
|
||||
|
||||
49
webui/src/components/basic/__tests__/ab-confirm.test.ts
Normal file
49
webui/src/components/basic/__tests__/ab-confirm.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import AbConfirmHost from '../ab-confirm-host.vue';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
async function tick() {
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
|
||||
describe('useConfirm + ab-confirm-host', () => {
|
||||
it('resolves 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();
|
||||
await expect(result).resolves.toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('resolves 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();
|
||||
await expect(result).resolves.toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders 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 });
|
||||
await tick();
|
||||
const ok = document.querySelector('.ab-confirm-ok') as HTMLButtonElement;
|
||||
expect(ok.className).toContain('ab-btn--danger');
|
||||
ok.click();
|
||||
await result;
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
58
webui/src/components/basic/__tests__/ab-field.test.ts
Normal file
58
webui/src/components/basic/__tests__/ab-field.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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
|
||||
const FakeControl = defineComponent({
|
||||
setup() {
|
||||
const field = inject(abFieldInjectionKey, null);
|
||||
return () =>
|
||||
h('input', {
|
||||
id: field?.controlId,
|
||||
'aria-describedby': field?.describedBy.value,
|
||||
'aria-invalid': field?.invalid.value || undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function mountField(props = {}) {
|
||||
return mount(AbField, {
|
||||
props: { label: 'RSS link', ...props },
|
||||
slots: { default: FakeControl },
|
||||
});
|
||||
}
|
||||
|
||||
describe('ab-field', () => {
|
||||
it('associates the label with the control via for/id', () => {
|
||||
const wrapper = mountField();
|
||||
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', () => {
|
||||
const wrapper = mountField({ required: true });
|
||||
expect(wrapper.find('.ab-field-required').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('wires description and error into aria-describedby', () => {
|
||||
const wrapper = mountField({
|
||||
description: 'Mikan personal feed.',
|
||||
error: 'Must be a URL',
|
||||
});
|
||||
const described = wrapper.find('input').attributes('aria-describedby');
|
||||
expect(described).toBeTruthy();
|
||||
for (const id of described!.split(' ')) {
|
||||
expect(wrapper.find(`#${id}`).exists()).toBe(true);
|
||||
}
|
||||
expect(wrapper.text()).toContain('Mikan personal feed.');
|
||||
expect(wrapper.text()).toContain('Must be a URL');
|
||||
});
|
||||
|
||||
it('marks the control invalid when error is set', () => {
|
||||
const wrapper = mountField({ error: 'Must be a URL' });
|
||||
expect(wrapper.find('input').attributes('aria-invalid')).toBe('true');
|
||||
});
|
||||
});
|
||||
54
webui/src/components/basic/__tests__/ab-input.test.ts
Normal file
54
webui/src/components/basic/__tests__/ab-input.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import AbInput from '../ab-input.vue';
|
||||
|
||||
describe('ab-input', () => {
|
||||
it('binds v-model', async () => {
|
||||
const wrapper = mount(AbInput, {
|
||||
props: {
|
||||
modelValue: 'abc',
|
||||
'onUpdate:modelValue': (v: string) =>
|
||||
wrapper.setProps({ modelValue: v }),
|
||||
},
|
||||
});
|
||||
await wrapper.find('input').setValue('https://mikanani.me');
|
||||
expect(wrapper.props('modelValue')).toBe('https://mikanani.me');
|
||||
});
|
||||
|
||||
it('applies 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 () => {
|
||||
const wrapper = mount(AbInput, {
|
||||
props: {
|
||||
modelValue: 'abc',
|
||||
clearable: true,
|
||||
'onUpdate:modelValue': (v: string) =>
|
||||
wrapper.setProps({ modelValue: v }),
|
||||
},
|
||||
});
|
||||
await wrapper.find('.ab-input-clear').trigger('click');
|
||||
expect(wrapper.props('modelValue')).toBe('');
|
||||
});
|
||||
|
||||
it('toggles password visibility', async () => {
|
||||
const wrapper = mount(AbInput, {
|
||||
props: { modelValue: 'secret', type: 'password' },
|
||||
});
|
||||
expect(wrapper.find('input').attributes('type')).toBe('password');
|
||||
await wrapper.find('.ab-input-reveal').trigger('click');
|
||||
expect(wrapper.find('input').attributes('type')).toBe('text');
|
||||
});
|
||||
|
||||
it('emits numbers for type=number', async () => {
|
||||
const wrapper = mount(AbInput, {
|
||||
props: { modelValue: 0, type: 'number' },
|
||||
});
|
||||
await wrapper.find('input').setValue('42');
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([42]);
|
||||
});
|
||||
});
|
||||
59
webui/src/components/basic/__tests__/ab-list.test.ts
Normal file
59
webui/src/components/basic/__tests__/ab-list.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import AbList from '../ab-list.vue';
|
||||
|
||||
const items = [
|
||||
{ id: 1, name: '葬送的芙莉莲 第二季' },
|
||||
{ id: 2, name: 'Re:从零开始的异世界生活' },
|
||||
{ id: 3, name: '怪兽8号' },
|
||||
];
|
||||
|
||||
describe('ab-list', () => {
|
||||
it('renders one row per item via the row slot', () => {
|
||||
const wrapper = mount(AbList, {
|
||||
props: { items },
|
||||
slots: { row: `<template #row="{ item }">{{ item.name }}</template>` },
|
||||
});
|
||||
expect(wrapper.findAll('.ab-list-row')).toHaveLength(3);
|
||||
expect(wrapper.text()).toContain('怪兽8号');
|
||||
});
|
||||
|
||||
it('emits selection updates when a checkbox is toggled', async () => {
|
||||
const wrapper = mount(AbList, {
|
||||
props: { items, selectable: true, selected: [] },
|
||||
});
|
||||
const boxes = wrapper.findAll('input[type="checkbox"]');
|
||||
// 第一个是全选,其后每行一个
|
||||
await boxes[1].setValue(true);
|
||||
expect(wrapper.emitted('update:selected')?.at(-1)).toEqual([[1]]);
|
||||
});
|
||||
|
||||
it('select-all toggles every row', async () => {
|
||||
const wrapper = mount(AbList, {
|
||||
props: { items, selectable: true, selected: [] },
|
||||
});
|
||||
await wrapper.find('.ab-list-selectall input').setValue(true);
|
||||
expect(wrapper.emitted('update:selected')?.at(-1)).toEqual([[1, 2, 3]]);
|
||||
});
|
||||
|
||||
it('shows 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', () => {
|
||||
const wrapper = mount(AbList, {
|
||||
props: { items: [] },
|
||||
slots: { empty: '<p id="empty-msg">nothing</p>' },
|
||||
});
|
||||
expect(wrapper.find('#empty-msg').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits 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 });
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,13 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import AbMenu from '../ab-menu.vue';
|
||||
|
||||
const items = (handler = vi.fn()) => [
|
||||
{ key: 'enable', label: 'Enable rule', handler },
|
||||
{ key: 'archive', label: () => 'Archive rule' },
|
||||
{ key: 'delete', label: 'Delete rule', danger: true },
|
||||
];
|
||||
function items(handler = vi.fn()) {
|
||||
return [
|
||||
{ key: 'enable', label: 'Enable rule', handler },
|
||||
{ key: 'archive', label: () => 'Archive rule' },
|
||||
{ key: 'delete', label: 'Delete rule', danger: true },
|
||||
];
|
||||
}
|
||||
|
||||
describe('ab-menu', () => {
|
||||
it('renders item labels (string and function) after opening', async () => {
|
||||
|
||||
69
webui/src/components/basic/__tests__/ab-modal.test.ts
Normal file
69
webui/src/components/basic/__tests__/ab-modal.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import AbModal from '../ab-modal.vue';
|
||||
|
||||
// headlessui teleports dialogs into document.body and its leave transition
|
||||
// never completes under happy-dom — wipe the body between tests.
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
// matchMedia is mocked to matches:false in setup.ts → desktop dialog branch
|
||||
async function mountModal(props = {}, slots = {}) {
|
||||
const wrapper = mount(AbModal, {
|
||||
props: { show: true, title: 'Delete rule?', ...props },
|
||||
slots: { default: '<p>body text</p>', ...slots },
|
||||
attachTo: document.body,
|
||||
});
|
||||
// headlessui mounts dialog content on the next tick (transition setup)
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('ab-modal', () => {
|
||||
it('renders 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 () => {
|
||||
const wrapper = await mountModal({ show: false });
|
||||
expect(document.body.textContent).not.toContain('Delete rule?');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes dialog semantics', async () => {
|
||||
const wrapper = await mountModal();
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders the footer slot', async () => {
|
||||
const wrapper = await mountModal(
|
||||
{},
|
||||
{ footer: '<button id="ok">OK</button>' }
|
||||
);
|
||||
expect(document.querySelector('#ok')).not.toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('emits update:show false when the close button is clicked', async () => {
|
||||
const wrapper = await mountModal();
|
||||
const close = document.querySelector(
|
||||
'.ab-modal-close'
|
||||
) as HTMLButtonElement;
|
||||
expect(close).not.toBeNull();
|
||||
close.click();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(wrapper.emitted('update:show')?.at(-1)).toEqual([false]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('hides the close button when closable is false', async () => {
|
||||
const wrapper = await mountModal({ closable: false });
|
||||
expect(document.querySelector('.ab-modal-close')).toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
38
webui/src/components/basic/__tests__/ab-segmented.test.ts
Normal file
38
webui/src/components/basic/__tests__/ab-segmented.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import AbSegmented from '../ab-segmented.vue';
|
||||
|
||||
const options = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Airing', value: 'airing' },
|
||||
{ label: 'Archived', value: 'archived' },
|
||||
];
|
||||
|
||||
describe('ab-segmented', () => {
|
||||
it('marks the selected option', () => {
|
||||
const wrapper = mount(AbSegmented, {
|
||||
props: { options, value: 'airing' },
|
||||
});
|
||||
const tabs = wrapper.findAll('[role="tab"]');
|
||||
expect(tabs[1].attributes('aria-selected')).toBe('true');
|
||||
expect(tabs[0].attributes('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('updates v-model on click', async () => {
|
||||
const wrapper = mount(AbSegmented, {
|
||||
props: { options, value: 'all' },
|
||||
});
|
||||
await wrapper.findAll('[role="tab"]')[2].trigger('click');
|
||||
expect(wrapper.emitted('update:value')?.[0]).toEqual(['archived']);
|
||||
});
|
||||
|
||||
it('moves selection with arrow keys', async () => {
|
||||
const wrapper = mount(AbSegmented, {
|
||||
props: { options, value: 'all' },
|
||||
});
|
||||
await wrapper.findAll('[role="tab"]')[0].trigger('keydown', {
|
||||
key: 'ArrowRight',
|
||||
});
|
||||
expect(wrapper.emitted('update:value')?.[0]).toEqual(['airing']);
|
||||
});
|
||||
});
|
||||
32
webui/src/components/basic/__tests__/ab-tag.test.ts
Normal file
32
webui/src/components/basic/__tests__/ab-tag.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import AbTag from '../ab-tag.vue';
|
||||
|
||||
describe('ab-tag', () => {
|
||||
it('renders the title text', () => {
|
||||
const wrapper = mount(AbTag, {
|
||||
props: { type: 'success', title: '葬送的芙莉莲 第二季' },
|
||||
});
|
||||
expect(wrapper.text()).toContain('葬送的芙莉莲 第二季');
|
||||
});
|
||||
|
||||
it('applies 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', () => {
|
||||
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 () => {
|
||||
const wrapper = mount(AbTag, {
|
||||
props: { type: 'info', title: 'S02E08', closable: true },
|
||||
});
|
||||
await wrapper.find('.ab-tag-close').trigger('click');
|
||||
expect(wrapper.emitted('close')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
120
webui/src/components/basic/ab-alert.vue
Normal file
120
webui/src/components/basic/ab-alert.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script lang="ts" setup>
|
||||
// Soft Ink 行内提示:透明底 + 细线框,语义由加粗的彩色引导词承载 —
|
||||
// 不用图标、不用彩色填充(拒绝 AI 模板味的着色警告框)。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type?: 'info' | 'warning' | 'danger';
|
||||
/** 加粗引导词,如 “Feed unreachable.”(语义色只落在这里) */
|
||||
title?: string;
|
||||
closable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'info',
|
||||
title: '',
|
||||
closable: false,
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ab-alert"
|
||||
:class="`ab-alert--${type}`"
|
||||
:role="type === 'info' ? 'status' : 'alert'"
|
||||
>
|
||||
<p class="ab-alert-text">
|
||||
<b v-if="title" class="ab-alert-title">{{ title }}</b>
|
||||
<slot></slot>
|
||||
</p>
|
||||
|
||||
<span v-if="$slots.action" class="ab-alert-action">
|
||||
<slot name="action"></slot>
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="ab-alert-close"
|
||||
:aria-label="$t('common.cancel')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" width="11" height="11">
|
||||
<path
|
||||
d="M6 6l12 12M18 6 6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 13px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ab-alert-text {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ab-alert-title {
|
||||
font-weight: 650;
|
||||
margin-right: 4px;
|
||||
|
||||
.ab-alert--info & {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ab-alert--warning & {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.ab-alert--danger & {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.ab-alert-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ab-alert-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: -2px -4px 0 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
webui/src/components/basic/ab-badge.vue
Normal file
57
webui/src/components/basic/ab-badge.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
// 计数/圆点徽标(通知中心等):溢出显示 max+,count 为 0 时隐藏。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
count?: number;
|
||||
/** 只显示小圆点(不显示数字) */
|
||||
dot?: boolean;
|
||||
max?: number;
|
||||
}>(),
|
||||
{
|
||||
count: 0,
|
||||
dot: false,
|
||||
max: 99,
|
||||
}
|
||||
);
|
||||
|
||||
const visible = computed(() => props.dot || props.count > 0);
|
||||
const text = computed(() =>
|
||||
props.count > props.max ? `${props.max}+` : String(props.count)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="visible"
|
||||
class="ab-badge"
|
||||
:class="{ 'ab-badge--dot': dot }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<template v-if="!dot">{{ text }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-danger);
|
||||
color: var(--color-white);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&--dot {
|
||||
min-width: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -146,6 +146,11 @@ function close() {
|
||||
<div class="ab-bottom-sheet__content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div v-if="$slots.footer" class="ab-bottom-sheet__footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
@@ -157,22 +162,22 @@ function close() {
|
||||
.ab-bottom-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
&__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: var(--z-modal-backdrop);
|
||||
background: var(--color-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
&__container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 101;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
@@ -181,7 +186,6 @@ function close() {
|
||||
|
||||
&__panel {
|
||||
position: relative;
|
||||
z-index: 102;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 85dvh; // Use dynamic viewport height for iOS Safari keyboard support
|
||||
@@ -244,5 +248,13 @@ function close() {
|
||||
scroll-margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
50
webui/src/components/basic/ab-confirm-host.vue
Normal file
50
webui/src/components/basic/ab-confirm-host.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import AbButton from './ab-button.vue';
|
||||
import AbModal from './ab-modal.vue';
|
||||
import { useConfirmState } from '@/hooks/useConfirm';
|
||||
|
||||
// 全局确认对话框宿主:挂载一次(App.vue),由 useConfirm() 驱动。
|
||||
const { state, settle } = useConfirmState();
|
||||
|
||||
const show = computed({
|
||||
get: () => state.current !== null,
|
||||
set: (value: boolean) => {
|
||||
if (!value) settle(false);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AbModal
|
||||
v-model:show="show"
|
||||
size="sm"
|
||||
:title="state.current?.title ?? ''"
|
||||
>
|
||||
<p v-if="state.current?.body" class="ab-confirm-body">
|
||||
{{ state.current.body }}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<AbButton class="ab-confirm-cancel" size="sm" @click="settle(false)">
|
||||
{{ state.current?.cancelText ?? $t('common.cancel') }}
|
||||
</AbButton>
|
||||
<AbButton
|
||||
class="ab-confirm-ok"
|
||||
size="sm"
|
||||
:variant="state.current?.danger ? 'danger' : 'primary'"
|
||||
@click="settle(true)"
|
||||
>
|
||||
{{ state.current?.confirmText ?? $t('common.confirm') }}
|
||||
</AbButton>
|
||||
</template>
|
||||
</AbModal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-confirm-body {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
53
webui/src/components/basic/ab-empty.vue
Normal file
53
webui/src/components/basic/ab-empty.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
// Soft Ink 空状态:左对齐的说明文字 + 一个下一步动作 —
|
||||
// 教用户界面怎么用,而不是居中图标块 + “暂无数据”。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
description?: string;
|
||||
}>(),
|
||||
{
|
||||
description: '',
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ab-empty">
|
||||
<p class="ab-empty-title">{{ title }}</p>
|
||||
<p v-if="description" class="ab-empty-description">{{ description }}</p>
|
||||
<div v-if="$slots.action" class="ab-empty-action">
|
||||
<slot name="action"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--color-border-hover);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.ab-empty-title {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ab-empty-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
.ab-empty-action {
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
32
webui/src/components/basic/ab-field.stories.ts
Normal file
32
webui/src/components/basic/ab-field.stories.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbField from './ab-field.vue';
|
||||
import AbInput from './ab-input.vue';
|
||||
|
||||
const meta: Meta<typeof AbField> = {
|
||||
title: 'basic/ab-field',
|
||||
component: AbField,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbField>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbField, AbInput },
|
||||
template: `
|
||||
<div style="display:flex;flex-direction:column;gap:20px;max-width:520px">
|
||||
<ab-field
|
||||
label="RSS link"
|
||||
description="Mikan personal feed. Token stays on this machine."
|
||||
required
|
||||
>
|
||||
<ab-input model-value="https://mikanani.me/RSS/MyBangumi" />
|
||||
</ab-field>
|
||||
<ab-field label="Rename offset" error="Must be an integer, e.g. -12">
|
||||
<ab-input model-value="abc" />
|
||||
</ab-field>
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
155
webui/src/components/basic/ab-field.vue
Normal file
155
webui/src/components/basic/ab-field.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { computed, provide, useId } from 'vue';
|
||||
import type { ComputedRef, InjectionKey } from 'vue';
|
||||
|
||||
export interface AbFieldContext {
|
||||
/** 原生控件应设置的 id(配合 <label for>) */
|
||||
controlId: string;
|
||||
/** 标签文本节点 id(无法用 for 关联的组件用 aria-labelledby) */
|
||||
labelId: string;
|
||||
/** description/error 节点 id 列表,控件应设为 aria-describedby */
|
||||
describedBy: ComputedRef<string | undefined>;
|
||||
/** 当前是否处于错误态(控件应设 aria-invalid 并显示错误样式) */
|
||||
invalid: ComputedRef<boolean>;
|
||||
}
|
||||
|
||||
export const abFieldInjectionKey: InjectionKey<AbFieldContext> =
|
||||
Symbol('ab-field');
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// 表单字段容器(取代 ab-label):label + description + error + aria 接线。
|
||||
// 控件放默认插槽;ab-input 等会自动消费注入的 id/aria 上下文。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string | (() => string);
|
||||
description?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
}>(),
|
||||
{
|
||||
description: '',
|
||||
error: '',
|
||||
required: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Vue 类型将 useId 标为可空(组件实例外);setup 内恒为 string
|
||||
const controlId = useId() as string;
|
||||
const labelId = useId() as string;
|
||||
const descriptionId = useId() as string;
|
||||
const errorId = useId() as string;
|
||||
|
||||
const labelText = computed(() =>
|
||||
typeof props.label === 'function' ? props.label() : props.label
|
||||
);
|
||||
|
||||
const describedBy = computed(() => {
|
||||
const ids: string[] = [];
|
||||
if (props.description) ids.push(descriptionId);
|
||||
if (props.error) ids.push(errorId);
|
||||
return ids.length > 0 ? ids.join(' ') : undefined;
|
||||
});
|
||||
|
||||
const invalid = computed(() => Boolean(props.error));
|
||||
|
||||
provide(abFieldInjectionKey, { controlId, 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">
|
||||
{{ labelText }}
|
||||
<span
|
||||
v-if="required"
|
||||
class="ab-field-required"
|
||||
aria-hidden="true"
|
||||
>*</span
|
||||
>
|
||||
</label>
|
||||
<p v-if="description" :id="descriptionId" class="ab-field-description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="ab-field-control">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" :id="errorId" class="ab-field-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ab-field-main {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.ab-field-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ab-field-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.ab-field-required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ab-field-description {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
.ab-field-control {
|
||||
flex-shrink: 0;
|
||||
|
||||
// 移动端控件占满整行(沿用 ab-label 的行为)
|
||||
width: 100%;
|
||||
|
||||
@include forTablet {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ab-field-error {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-danger);
|
||||
|
||||
@include forTablet {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,10 +5,15 @@ const props = withDefaults(
|
||||
/** 无文本按钮的可访问名称(必填) */
|
||||
label: string;
|
||||
size?: 'sm' | 'md';
|
||||
/** solid:主色填充(用于卡片上的主要动作);round:圆形 */
|
||||
variant?: 'ghost' | 'solid';
|
||||
round?: boolean;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
size: 'md',
|
||||
variant: 'ghost',
|
||||
round: false,
|
||||
disabled: false,
|
||||
}
|
||||
);
|
||||
@@ -24,7 +29,11 @@ function onClick(event: MouseEvent) {
|
||||
<template>
|
||||
<button
|
||||
class="ab-icon-btn"
|
||||
:class="`ab-icon-btn--${size}`"
|
||||
:class="[
|
||||
`ab-icon-btn--${size}`,
|
||||
`ab-icon-btn--${variant}`,
|
||||
round && 'ab-icon-btn--round',
|
||||
]"
|
||||
type="button"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
@@ -85,5 +94,19 @@ function onClick(event: MouseEvent) {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
&--solid {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&--round {
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
32
webui/src/components/basic/ab-input.stories.ts
Normal file
32
webui/src/components/basic/ab-input.stories.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import AbInput from './ab-input.vue';
|
||||
|
||||
const meta: Meta<typeof AbInput> = {
|
||||
title: 'basic/ab-input',
|
||||
component: AbInput,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbInput>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbInput },
|
||||
setup() {
|
||||
const text = ref('https://mikanani.me/RSS/MyBangumi');
|
||||
const secret = ref('token-123');
|
||||
const broken = ref('abc');
|
||||
return { text, secret, broken };
|
||||
},
|
||||
template: `
|
||||
<div style="display:flex;flex-direction:column;gap:14px;max-width:320px">
|
||||
<ab-input v-model="text" clearable placeholder="RSS link" />
|
||||
<ab-input v-model="secret" type="password" />
|
||||
<ab-input v-model="broken" error />
|
||||
<ab-input model-value="disabled" disabled />
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
240
webui/src/components/basic/ab-input.vue
Normal file
240
webui/src/components/basic/ab-input.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { abFieldInjectionKey } from './ab-field.vue';
|
||||
|
||||
// 文本输入组件(取代 UnoCSS 的 ab-input 快捷类):
|
||||
// Soft Ink 填充式,左对齐;支持前后缀、清空、密码可见切换、错误态。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'text' | 'password' | 'number' | 'url' | 'search';
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
/** 错误态样式;错误文案由外层 ab-field 显示 */
|
||||
error?: boolean;
|
||||
ariaLabel?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
clearable: false,
|
||||
error: false,
|
||||
ariaLabel: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
step: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const model = defineModel<string | number>({ default: '' });
|
||||
|
||||
const field = inject(abFieldInjectionKey, null);
|
||||
|
||||
const revealed = ref(false);
|
||||
|
||||
const effectiveType = computed(() => {
|
||||
if (props.type === 'password') return revealed.value ? 'text' : 'password';
|
||||
return props.type;
|
||||
});
|
||||
|
||||
const invalid = computed(() => props.error || field?.invalid.value === true);
|
||||
|
||||
function onInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
model.value = props.type === 'number' && value !== '' ? Number(value) : value;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
model.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="ab-input"
|
||||
:class="{
|
||||
'ab-input--error': invalid,
|
||||
'ab-input--disabled': disabled,
|
||||
}"
|
||||
>
|
||||
<span v-if="$slots.prefix" class="ab-input-affix">
|
||||
<slot name="prefix"></slot>
|
||||
</span>
|
||||
|
||||
<input
|
||||
:id="field?.controlId"
|
||||
:value="model"
|
||||
:type="effectiveType"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-describedby="field?.describedBy.value"
|
||||
:aria-invalid="invalid || undefined"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="clearable && String(model).length > 0 && !disabled"
|
||||
type="button"
|
||||
class="ab-input-clear"
|
||||
:aria-label="$t('common.clear')"
|
||||
tabindex="-1"
|
||||
@click="clear"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" width="12" height="12">
|
||||
<path
|
||||
d="M6 6l12 12M18 6 6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="type === 'password'"
|
||||
type="button"
|
||||
class="ab-input-reveal"
|
||||
:aria-pressed="revealed"
|
||||
:aria-label="$t('common.toggle_password')"
|
||||
@click="revealed = !revealed"
|
||||
>
|
||||
<svg
|
||||
v-if="revealed"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path
|
||||
d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<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
|
||||
d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 4l16 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span v-if="$slots.suffix" class="ab-input-affix">
|
||||
<slot name="suffix"></slot>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 11px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color var(--transition-fast),
|
||||
background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
|
||||
@include forTablet {
|
||||
width: 200px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: var(--color-danger);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ab-input-affix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ab-input-clear,
|
||||
.ab-input-reveal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
210
webui/src/components/basic/ab-list.vue
Normal file
210
webui/src/components/basic/ab-list.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import AbSkeleton from './ab-skeleton.vue';
|
||||
import AbEmpty from './ab-empty.vue';
|
||||
|
||||
type ListItem = Record<string, any>;
|
||||
|
||||
// Soft Ink 列表(取代 ab-data-list):surface-2 填充行、悬停/聚焦态、
|
||||
// 受控选择、加载骨架、i18n 空状态;density 供桌面数据页用紧凑行。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ListItem[];
|
||||
keyField?: string;
|
||||
selectable?: boolean;
|
||||
/** 受控选择:v-model:selected */
|
||||
selected?: unknown[];
|
||||
loading?: boolean;
|
||||
density?: 'comfortable' | 'compact';
|
||||
}>(),
|
||||
{
|
||||
keyField: 'id',
|
||||
selectable: false,
|
||||
selected: () => [],
|
||||
loading: false,
|
||||
density: 'comfortable',
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [keys: unknown[]];
|
||||
'row-click': [item: ListItem];
|
||||
}>();
|
||||
|
||||
const selectedSet = computed(() => new Set(props.selected));
|
||||
|
||||
const allSelected = computed(
|
||||
() => props.items.length > 0 && selectedSet.value.size === props.items.length
|
||||
);
|
||||
|
||||
const someSelected = computed(
|
||||
() => selectedSet.value.size > 0 && !allSelected.value
|
||||
);
|
||||
|
||||
function toggle(key: unknown) {
|
||||
const next = new Set(selectedSet.value);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
emit('update:selected', Array.from(next));
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allSelected.value) {
|
||||
emit('update:selected', []);
|
||||
} else {
|
||||
emit(
|
||||
'update:selected',
|
||||
props.items.map((item) => item[props.keyField])
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ab-list" :class="`ab-list--${density}`">
|
||||
<!-- 全选头(可选择时) -->
|
||||
<div v-if="selectable && items.length > 0" class="ab-list-header">
|
||||
<label class="ab-list-selectall">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
@change="toggleAll"
|
||||
/>
|
||||
<span>{{ $t('common.selectAll') }}</span>
|
||||
</label>
|
||||
<span class="ab-list-count"
|
||||
>{{ items.length }} {{ $t('common.items') }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 加载骨架 -->
|
||||
<AbSkeleton v-if="loading" preset="row" :count="3" />
|
||||
|
||||
<!-- 行 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item[keyField]"
|
||||
class="ab-list-row"
|
||||
tabindex="0"
|
||||
@click="emit('row-click', item)"
|
||||
@keydown.enter="emit('row-click', item)"
|
||||
>
|
||||
<label
|
||||
v-if="selectable"
|
||||
class="ab-list-check"
|
||||
@click.stop
|
||||
@keydown.enter.stop
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedSet.has(item[keyField])"
|
||||
:aria-label="$t('common.select')"
|
||||
@change="toggle(item[keyField])"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="ab-list-row-content">
|
||||
<slot name="row" :item="item">
|
||||
{{ item[keyField] }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="items.length === 0" class="ab-list-empty">
|
||||
<slot name="empty">
|
||||
<AbEmpty :title="$t('common.empty')"></AbEmpty>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ab-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.ab-list-selectall {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ab-list-count {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ab-list input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ab-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.ab-list--compact & {
|
||||
padding: 7px 12px;
|
||||
gap: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.ab-list-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
// 扩大命中区
|
||||
padding: 6px;
|
||||
margin: -6px;
|
||||
}
|
||||
|
||||
.ab-list-row-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ab-list-empty {
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
39
webui/src/components/basic/ab-modal.stories.ts
Normal file
39
webui/src/components/basic/ab-modal.stories.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import AbButton from './ab-button.vue';
|
||||
import AbModal from './ab-modal.vue';
|
||||
|
||||
const meta: Meta<typeof AbModal> = {
|
||||
title: 'basic/ab-modal',
|
||||
component: AbModal,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbModal>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbModal, AbButton },
|
||||
setup() {
|
||||
const show = ref(false);
|
||||
return { show };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<ab-button variant="primary" @click="show = true">Open modal</ab-button>
|
||||
<ab-modal v-model:show="show" title="Delete rule?" size="sm">
|
||||
<p style="margin:0;color:var(--color-text-secondary)">
|
||||
“葬送的芙莉莲 第二季” stops updating. Downloaded files stay on disk.
|
||||
</p>
|
||||
<template #footer>
|
||||
<ab-button size="sm" @click="show = false">Cancel</ab-button>
|
||||
<ab-button size="sm" variant="danger" @click="show = false">
|
||||
Delete rule
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-modal>
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
197
webui/src/components/basic/ab-modal.vue
Normal file
197
webui/src/components/basic/ab-modal.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue';
|
||||
import { Close } from '@icon-park/vue-next';
|
||||
import AbBottomSheet from './ab-bottom-sheet.vue';
|
||||
import AbIconButton from './ab-icon-button.vue';
|
||||
import { useBreakpointQuery } from '@/hooks/useBreakpointQuery';
|
||||
|
||||
// 唯一的自适应弹窗组件(合并旧 ab-popup / ab-adaptive-modal):
|
||||
// 桌面居中对话框,移动端底部抽屉。headlessui Dialog 提供焦点陷阱与 aria。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** false 时禁用遮罩点击/关闭按钮(用于流程中不可中断的弹窗) */
|
||||
closable?: boolean;
|
||||
maxHeight?: string;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
size: 'md',
|
||||
closable: true,
|
||||
maxHeight: '85dvh',
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const show = defineModel<boolean>('show', { default: false });
|
||||
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
|
||||
function close() {
|
||||
if (!props.closable) return;
|
||||
show.value = false;
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 移动端:底部抽屉 -->
|
||||
<AbBottomSheet
|
||||
v-if="isMobile"
|
||||
:show="show"
|
||||
:title="title"
|
||||
:closeable="closable"
|
||||
:max-height="maxHeight"
|
||||
@update:show="show = $event"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<slot />
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</AbBottomSheet>
|
||||
|
||||
<!-- 桌面/平板:居中对话框 -->
|
||||
<TransitionRoot v-else appear :show="show" as="template">
|
||||
<Dialog class="ab-modal" @close="close">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-200 ease-out"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="duration-150 ease-in"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="ab-modal-backdrop" aria-hidden="true" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="ab-modal-wrapper">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-200 ease-out"
|
||||
enter-from="opacity-0 scale-97"
|
||||
enter-to="opacity-100 scale-100"
|
||||
leave="duration-150 ease-in"
|
||||
leave-from="opacity-100 scale-100"
|
||||
leave-to="opacity-0 scale-97"
|
||||
>
|
||||
<DialogPanel
|
||||
class="ab-modal-panel"
|
||||
:class="`ab-modal-panel--${size}`"
|
||||
>
|
||||
<header v-if="title || closable" class="ab-modal-header">
|
||||
<DialogTitle as="h2" class="ab-modal-title">
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<AbIconButton
|
||||
v-if="closable"
|
||||
class="ab-modal-close"
|
||||
size="sm"
|
||||
:label="$t('common.cancel')"
|
||||
@click="close"
|
||||
>
|
||||
<Close theme="outline" size="16" />
|
||||
</AbIconButton>
|
||||
</header>
|
||||
|
||||
<div class="ab-modal-body" :style="{ maxHeight }">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer v-if="$slots.footer" class="ab-modal-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-modal {
|
||||
position: relative;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.ab-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal-backdrop);
|
||||
background: var(--color-overlay);
|
||||
}
|
||||
|
||||
.ab-modal-wrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ab-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
|
||||
&--sm {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
&--md {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
&--lg {
|
||||
max-width: 680px;
|
||||
}
|
||||
}
|
||||
|
||||
.ab-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 13px 18px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ab-modal-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ab-modal-body {
|
||||
flex: 1;
|
||||
padding: 16px 18px;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ab-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import { Caution, Close } from '@icon-park/vue-next';
|
||||
import type { OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';
|
||||
|
||||
@@ -216,20 +215,19 @@ function handleCancel() {
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="dialog-footer">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('offset.cancel') }}
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="handleKeep">
|
||||
</ab-button>
|
||||
<ab-button variant="primary" size="sm" @click="handleKeep">
|
||||
{{ t('offset.keep') }}
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="handleApply">
|
||||
</ab-button>
|
||||
<ab-button size="sm" variant="primary" @click="handleApply">
|
||||
{{ t('offset.apply') }}
|
||||
</NButton>
|
||||
</ab-button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
79
webui/src/components/basic/ab-progress.vue
Normal file
79
webui/src/components/basic/ab-progress.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts" setup>
|
||||
// 细进度条(下载页):轨道用 surface-2,进行中为主色,错误为 danger。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 0–100 */
|
||||
value: number;
|
||||
label?: string;
|
||||
state?: 'active' | 'error';
|
||||
}>(),
|
||||
{
|
||||
value: 0,
|
||||
label: '',
|
||||
state: 'active',
|
||||
}
|
||||
);
|
||||
|
||||
const clamped = computed(() => Math.min(100, Math.max(0, props.value)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ab-progress">
|
||||
<div
|
||||
class="ab-progress-track"
|
||||
role="progressbar"
|
||||
:aria-valuenow="Math.round(clamped)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-label="label || undefined"
|
||||
>
|
||||
<div
|
||||
class="ab-progress-fill"
|
||||
:class="`ab-progress-fill--${state}`"
|
||||
:style="{ transform: `scaleX(${clamped / 100})` }"
|
||||
></div>
|
||||
</div>
|
||||
<span v-if="label" class="ab-progress-label">{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ab-progress-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ab-progress-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
transform-origin: left center;
|
||||
transition: transform var(--transition-normal);
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.ab-progress-label {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
30
webui/src/components/basic/ab-segmented.stories.ts
Normal file
30
webui/src/components/basic/ab-segmented.stories.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import AbSegmented from './ab-segmented.vue';
|
||||
|
||||
const meta: Meta<typeof AbSegmented> = {
|
||||
title: 'basic/ab-segmented',
|
||||
component: AbSegmented,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbSegmented>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbSegmented },
|
||||
setup() {
|
||||
const view = ref('all');
|
||||
const options = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Airing', value: 'airing' },
|
||||
{ label: 'Archived', value: 'archived' },
|
||||
];
|
||||
return { view, options };
|
||||
},
|
||||
template:
|
||||
'<ab-segmented v-model:value="view" :options="options" aria-label="View" />',
|
||||
}),
|
||||
};
|
||||
117
webui/src/components/basic/ab-segmented.vue
Normal file
117
webui/src/components/basic/ab-segmented.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
export interface AbSegmentedOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 视图/筛选切换:Soft Ink 下划线标签页(无浮动滑块),方向键可导航。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: AbSegmentedOption[];
|
||||
size?: 'sm' | 'md';
|
||||
ariaLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
size: 'md',
|
||||
ariaLabel: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const value = defineModel<string>('value', { required: true });
|
||||
|
||||
const tabRefs = ref<HTMLButtonElement[]>([]);
|
||||
|
||||
function select(option: AbSegmentedOption) {
|
||||
value.value = option.value;
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent, index: number) {
|
||||
let next: number | null = null;
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % props.options.length;
|
||||
if (event.key === 'ArrowLeft')
|
||||
next = (index - 1 + props.options.length) % props.options.length;
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
value.value = props.options[next].value;
|
||||
tabRefs.value[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ab-segmented"
|
||||
:class="`ab-segmented--${size}`"
|
||||
role="tablist"
|
||||
:aria-label="ariaLabel"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
:ref="(el) => (tabRefs[index] = el as HTMLButtonElement)"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="ab-segmented-tab"
|
||||
:class="{ 'ab-segmented-tab--on': option.value === value }"
|
||||
:aria-selected="option.value === value"
|
||||
:tabindex="option.value === value ? 0 : -1"
|
||||
@click="select(option)"
|
||||
@keydown="onKeydown($event, index)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-segmented {
|
||||
display: inline-flex;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ab-segmented-tab {
|
||||
padding: 6px 2px;
|
||||
margin-bottom: -1px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
|
||||
@include forTouch {
|
||||
padding: 10px 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&--on {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ab-segmented--sm .ab-segmented-tab {
|
||||
font-size: 12px;
|
||||
padding: 4px 2px;
|
||||
|
||||
@include forTouch {
|
||||
padding: 8px 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
webui/src/components/basic/ab-select.stories.ts
Normal file
29
webui/src/components/basic/ab-select.stories.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import AbSelect from './ab-select.vue';
|
||||
|
||||
const meta: Meta<typeof AbSelect> = {
|
||||
title: 'basic/ab-select',
|
||||
component: AbSelect,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbSelect>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbSelect },
|
||||
setup() {
|
||||
const value = ref('mikan');
|
||||
return { value };
|
||||
},
|
||||
template: `
|
||||
<ab-select
|
||||
v-model="value"
|
||||
:items="['mikan', 'dmhy', 'nyaa']"
|
||||
style="width:200px"
|
||||
/>`,
|
||||
}),
|
||||
};
|
||||
72
webui/src/components/basic/ab-select.vue
Normal file
72
webui/src/components/basic/ab-select.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import { NSelect } from 'naive-ui';
|
||||
import { isObject } from 'radash';
|
||||
import type { SelectOption } from 'naive-ui';
|
||||
import { abFieldInjectionKey } from './ab-field.vue';
|
||||
import type { SelectItem } from '#/components';
|
||||
|
||||
// NSelect 的统一封装:选项归一化 + ab-field aria 接线在这里定一次。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 允许 string 或 {label?, value} 混合(旧 ab-setting 的格式) */
|
||||
items?: Array<SelectItem | string>;
|
||||
/** 已是 naive 格式时直接传 options */
|
||||
options?: SelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
ariaLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
items: undefined,
|
||||
options: undefined,
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
error: false,
|
||||
ariaLabel: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const model = defineModel<string | number | null>({ default: null });
|
||||
|
||||
const field = inject(abFieldInjectionKey, null);
|
||||
|
||||
const normalizedOptions = computed<SelectOption[]>(() => {
|
||||
if (props.options) return props.options;
|
||||
return (props.items ?? []).map((item) =>
|
||||
isObject(item)
|
||||
? { label: item.label ?? String(item.value), value: item.value }
|
||||
: { label: item, value: item }
|
||||
);
|
||||
});
|
||||
|
||||
const invalid = computed(() => props.error || field?.invalid.value === true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSelect
|
||||
v-model:value="model"
|
||||
class="ab-select"
|
||||
:class="{ 'ab-select--error': invalid }"
|
||||
:options="normalizedOptions"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-labelledby="field?.labelId"
|
||||
:aria-describedby="field?.describedBy.value"
|
||||
></NSelect>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-select {
|
||||
min-width: 140px;
|
||||
max-width: 100%;
|
||||
|
||||
&--error {
|
||||
:deep(.n-base-selection) {
|
||||
--n-border: 1px solid var(--color-danger) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
118
webui/src/components/basic/ab-skeleton.vue
Normal file
118
webui/src/components/basic/ab-skeleton.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
// 骨架屏(加载态用骨架,不在内容中放 spinner)。
|
||||
// preset 对应真实布局:lines(文本)/ row(列表行)/ card(卡片)。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
preset?: 'lines' | 'row' | 'card';
|
||||
count?: number;
|
||||
}>(),
|
||||
{
|
||||
preset: 'lines',
|
||||
count: 3,
|
||||
}
|
||||
);
|
||||
|
||||
// 行宽做点参差,避免整齐得像占位框
|
||||
const widths = ['62%', '88%', '40%', '75%', '55%'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ab-skeleton" aria-hidden="true">
|
||||
<template v-if="preset === 'lines'">
|
||||
<span
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="ab-skeleton-line"
|
||||
:style="{ width: widths[(i - 1) % widths.length] }"
|
||||
></span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="preset === 'row'">
|
||||
<span v-for="i in count" :key="i" class="ab-skeleton-row">
|
||||
<span class="ab-skeleton-line" style="width: 46%"></span>
|
||||
<span
|
||||
class="ab-skeleton-line ab-skeleton-line--dim"
|
||||
style="width: 68%"
|
||||
></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span v-for="i in count" :key="i" class="ab-skeleton-card"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@mixin shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-surface-2) 25%,
|
||||
var(--color-surface-hover) 45%,
|
||||
var(--color-surface-2) 65%
|
||||
);
|
||||
background-size: 240% 100%;
|
||||
animation: ab-skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ab-skeleton-line {
|
||||
height: 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
@include shimmer;
|
||||
|
||||
&--dim {
|
||||
height: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.ab-skeleton-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-2);
|
||||
|
||||
.ab-skeleton-line {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-surface-hover) 25%,
|
||||
var(--color-surface) 45%,
|
||||
var(--color-surface-hover) 65%
|
||||
);
|
||||
background-size: 240% 100%;
|
||||
animation: ab-skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ab-skeleton-card {
|
||||
height: 120px;
|
||||
border-radius: var(--radius-md);
|
||||
@include shimmer;
|
||||
}
|
||||
|
||||
@keyframes ab-skeleton-shimmer {
|
||||
from {
|
||||
background-position: 120% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -120% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -24,10 +24,10 @@ const props = withDefaults(
|
||||
}
|
||||
);
|
||||
|
||||
const value = defineModel<string>('value', { required: true });
|
||||
|
||||
const emit = defineEmits<{ click: [value: string] }>();
|
||||
|
||||
const value = defineModel<string>('value', { required: true });
|
||||
|
||||
const selected = computed(
|
||||
() =>
|
||||
props.options.find((option) => option.value === value.value) ??
|
||||
@@ -63,7 +63,7 @@ function onMainClick() {
|
||||
{{ selected?.label }}
|
||||
</button>
|
||||
|
||||
<ab-menu :items="menuItems" align="right">
|
||||
<AbMenu :items="menuItems" align="right">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
@@ -74,7 +74,7 @@ function onMainClick() {
|
||||
<Down size="12" />
|
||||
</button>
|
||||
</template>
|
||||
</ab-menu>
|
||||
</AbMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,12 +6,19 @@ const meta: Meta<typeof AbStatus> = {
|
||||
title: 'basic/ab-status',
|
||||
component: AbStatus,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
state: {
|
||||
control: 'select',
|
||||
options: ['running', 'stopped', 'paused', 'degraded'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbStatus>;
|
||||
|
||||
export const Template: Story = {
|
||||
args: { state: 'running', label: 'Running', detail: 'next poll 3 min' },
|
||||
render: (args) => ({
|
||||
components: { AbStatus },
|
||||
setup() {
|
||||
@@ -20,3 +27,17 @@ export const Template: Story = {
|
||||
template: '<ab-status v-bind="args" />',
|
||||
}),
|
||||
};
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { AbStatus },
|
||||
template: `
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<ab-status state="running" label="Running" detail="next poll 3 min" />
|
||||
<ab-status state="stopped" label="Stopped" detail="downloader unreachable" />
|
||||
<ab-status state="paused" label="Paused" detail="by user" />
|
||||
<ab-status state="degraded" label="Degraded" detail="2 feeds failing" />
|
||||
<ab-status state="running" size="sm" label="Compact" />
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,70 +1,109 @@
|
||||
<script lang="ts" setup>
|
||||
withDefaults(
|
||||
// Soft Ink 状态标记:方形色块 + 墨色文字。页面上唯一的语义色载体,
|
||||
// 回答“是否在正常工作”。四种状态取代旧的 running 布尔值。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
running: boolean;
|
||||
size?: string;
|
||||
state: 'running' | 'stopped' | 'paused' | 'degraded';
|
||||
/** 状态文字(省略则只渲染色块,需自带 aria 上下文) */
|
||||
label?: string;
|
||||
/** 次要说明,如 “next poll 3 min” */
|
||||
detail?: string;
|
||||
size?: 'sm' | 'md';
|
||||
}>(),
|
||||
{
|
||||
running: false,
|
||||
size: '1em',
|
||||
label: '',
|
||||
detail: '',
|
||||
size: 'md',
|
||||
}
|
||||
);
|
||||
|
||||
const ariaText = computed(() => props.label || props.state);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="status-indicator"
|
||||
:style="{ width: size, height: size }"
|
||||
<span
|
||||
class="ab-status"
|
||||
:class="[`ab-status--${state}`, `ab-status--${size}`]"
|
||||
role="status"
|
||||
:aria-label="running ? 'System running' : 'System stopped'"
|
||||
:aria-label="detail ? `${ariaText} — ${detail}` : ariaText"
|
||||
>
|
||||
<div class="status-ring">
|
||||
<div
|
||||
class="status-dot"
|
||||
:class="[running ? 'status-dot--running' : 'status-dot--stopped']"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ab-status-mark" aria-hidden="true"></span>
|
||||
<span v-if="label" class="ab-status-label">{{ label }}</span>
|
||||
<span v-if="detail" class="ab-status-detail">{{ detail }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
.ab-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
|
||||
&--sm {
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-border);
|
||||
transition: border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
.ab-status-mark {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border-radius: 2.5px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&--running {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 40%, transparent);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
.ab-status--sm & {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&--stopped {
|
||||
.ab-status--running & {
|
||||
background: var(--color-success);
|
||||
animation: ab-status-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ab-status--stopped & {
|
||||
background: var(--color-danger);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
}
|
||||
|
||||
.ab-status--paused & {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ab-status--degraded & {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
.ab-status-label {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ab-status-detail {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 400;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes ab-status-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ab-status-mark {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
30
webui/src/components/basic/ab-switch.stories.ts
Normal file
30
webui/src/components/basic/ab-switch.stories.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import AbSwitch from './ab-switch.vue';
|
||||
|
||||
const meta: Meta<typeof AbSwitch> = {
|
||||
title: 'basic/ab-switch',
|
||||
component: AbSwitch,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbSwitch>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbSwitch },
|
||||
setup() {
|
||||
const on = ref(true);
|
||||
const off = ref(false);
|
||||
return { on, off };
|
||||
},
|
||||
template: `
|
||||
<div style="display:flex;gap:14px;align-items:center">
|
||||
<ab-switch v-model="on" aria-label="Auto rename" />
|
||||
<ab-switch v-model="off" aria-label="Proxy" />
|
||||
<ab-switch :model-value="true" disabled aria-label="Disabled" />
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
36
webui/src/components/basic/ab-switch.vue
Normal file
36
webui/src/components/basic/ab-switch.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import { inject } from 'vue';
|
||||
import { NSwitch } from 'naive-ui';
|
||||
import { abFieldInjectionKey } from './ab-field.vue';
|
||||
|
||||
// NSwitch 的统一封装:尺寸、loading 与 aria 接线在这里定一次。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
ariaLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
size: 'medium',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
ariaLabel: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const model = defineModel<boolean>({ default: false });
|
||||
|
||||
const field = inject(abFieldInjectionKey, null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSwitch
|
||||
v-model:value="model"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-labelledby="field?.labelId"
|
||||
></NSwitch>
|
||||
</template>
|
||||
@@ -6,12 +6,19 @@ const meta: Meta<typeof AbTag> = {
|
||||
title: 'basic/ab-tag',
|
||||
component: AbTag,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['success', 'warning', 'danger', 'info', 'neutral'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbTag>;
|
||||
|
||||
export const Template: Story = {
|
||||
args: { type: 'success', title: 'Airing' },
|
||||
render: (args) => ({
|
||||
components: { AbTag },
|
||||
setup() {
|
||||
@@ -20,3 +27,19 @@ export const Template: Story = {
|
||||
template: '<ab-tag v-bind="args" />',
|
||||
}),
|
||||
};
|
||||
|
||||
export const AllTypes: Story = {
|
||||
render: () => ({
|
||||
components: { AbTag },
|
||||
template: `
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<ab-tag type="success" title="Airing" />
|
||||
<ab-tag type="neutral" title="Archived" />
|
||||
<ab-tag type="info" title="S02E08" />
|
||||
<ab-tag type="warning" title="Needs review" />
|
||||
<ab-tag type="danger" title="Parse failed" />
|
||||
<ab-tag type="info" title="葬送的芙莉莲 第二季" closable />
|
||||
<ab-tag type="neutral" title="桜都字幕组 · 简日双语 · 1080p" />
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,67 +1,120 @@
|
||||
<script lang="ts" setup>
|
||||
// Soft Ink 标签:细线框 + 语义色方块标记,文字始终为墨色 —
|
||||
// 颜色不承载文字(拒绝粉彩胶囊)。不截断 CJK 标题。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type: 'primary' | 'warn' | 'inactive' | 'active' | 'notify';
|
||||
title: string;
|
||||
type?: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
|
||||
title?: string;
|
||||
closable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'primary',
|
||||
title: 'title',
|
||||
type: 'neutral',
|
||||
title: '',
|
||||
closable: false,
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="tag" :class="`tag--${type}`">
|
||||
{{ title }}
|
||||
<span class="ab-tag" :class="`ab-tag--${type}`">
|
||||
<span class="ab-tag-text"><slot>{{ title }}</slot></span>
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="ab-tag-close"
|
||||
:aria-label="$t('common.cancel')"
|
||||
@click.stop="$emit('close')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" width="10" height="10">
|
||||
<path
|
||||
d="M6 6l12 12M18 6 6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag {
|
||||
.ab-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
max-width: 80px;
|
||||
transition: border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
|
||||
// 语义色方块标记
|
||||
&::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--success::before {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
&--warning::before {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
&--danger::before {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
&--info::before {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
&--neutral {
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&::before {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ab-tag-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 1px solid;
|
||||
transition: background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--color-primary-light);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
.ab-tag-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
background: var(--color-surface-hover);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
&--notify {
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
64
webui/src/components/basic/ab-toolbar.vue
Normal file
64
webui/src/components/basic/ab-toolbar.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
// 列表上方的操作行:搜索 / 筛选 / 动作三个插槽,一处定义响应式换行。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 吸顶(长列表页) */
|
||||
sticky?: boolean;
|
||||
}>(),
|
||||
{
|
||||
sticky: false,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ab-toolbar" :class="{ 'ab-toolbar--sticky': sticky }">
|
||||
<div v-if="$slots.search" class="ab-toolbar-search">
|
||||
<slot name="search"></slot>
|
||||
</div>
|
||||
<div v-if="$slots.filters" class="ab-toolbar-filters">
|
||||
<slot name="filters"></slot>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="ab-toolbar-actions">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
&--sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ab-toolbar-search {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.ab-toolbar-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ab-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
27
webui/src/components/basic/ab-tooltip.stories.ts
Normal file
27
webui/src/components/basic/ab-tooltip.stories.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbButton from './ab-button.vue';
|
||||
import AbTooltip from './ab-tooltip.vue';
|
||||
|
||||
const meta: Meta<typeof AbTooltip> = {
|
||||
title: 'basic/ab-tooltip',
|
||||
component: AbTooltip,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbTooltip>;
|
||||
|
||||
export const Template: Story = {
|
||||
args: { content: 'Refresh all RSS feeds' },
|
||||
render: (args) => ({
|
||||
components: { AbTooltip, AbButton },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: `
|
||||
<ab-tooltip v-bind="args">
|
||||
<ab-button variant="secondary">Hover me</ab-button>
|
||||
</ab-tooltip>`,
|
||||
}),
|
||||
};
|
||||
34
webui/src/components/basic/ab-tooltip.vue
Normal file
34
webui/src/components/basic/ab-tooltip.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
import { NTooltip } from 'naive-ui';
|
||||
|
||||
// NTooltip 的统一封装:延迟、触屏回退(点击触发)与样式只在这里定一次。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
content?: string;
|
||||
placement?:
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end';
|
||||
delay?: number;
|
||||
}>(),
|
||||
{
|
||||
content: '',
|
||||
placement: 'top',
|
||||
delay: 400,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip :placement="placement" :delay="delay" trigger="hover">
|
||||
<template #trigger>
|
||||
<slot />
|
||||
</template>
|
||||
<slot name="content">{{ content }}</slot>
|
||||
</NTooltip>
|
||||
</template>
|
||||
43
webui/src/hooks/useConfirm.ts
Normal file
43
webui/src/hooks/useConfirm.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string;
|
||||
body?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
/** 破坏性操作:确认按钮使用 danger 变体 */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmRequest extends ConfirmOptions {
|
||||
resolve: (value: boolean) => void;
|
||||
}
|
||||
|
||||
// 模块级单例状态:ab-confirm-host(挂载于 App.vue)消费并渲染
|
||||
const state = reactive<{ current: ConfirmRequest | null }>({ current: null });
|
||||
|
||||
/**
|
||||
* Promise 式确认对话框,替代 NPopconfirm / 手搭确认弹窗:
|
||||
* `if (await confirm({ title, danger: true })) { … }`
|
||||
*/
|
||||
export function useConfirm() {
|
||||
function confirm(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// 已有未决确认时,先取消旧的,避免悬挂的 Promise
|
||||
state.current?.resolve(false);
|
||||
state.current = { ...options, resolve };
|
||||
});
|
||||
}
|
||||
|
||||
return { confirm };
|
||||
}
|
||||
|
||||
/** 仅供 ab-confirm-host 内部使用 */
|
||||
export function useConfirmState() {
|
||||
function settle(value: boolean) {
|
||||
state.current?.resolve(value);
|
||||
state.current = null;
|
||||
}
|
||||
|
||||
return { state, settle };
|
||||
}
|
||||
@@ -1,7 +1,35 @@
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||
import { createSharedComposable } from '@vueuse/core';
|
||||
|
||||
// Soft Ink toast:反色墨条 —— 所有类型同一深底,语义色只出现在图标上。
|
||||
// (组件级 token 可以用 CSS 变量;只有 common 必须是字面量)
|
||||
const toastTheme: GlobalThemeOverrides = {
|
||||
Message: {
|
||||
color: 'var(--color-text)',
|
||||
colorInfo: 'var(--color-text)',
|
||||
colorSuccess: 'var(--color-text)',
|
||||
colorError: 'var(--color-text)',
|
||||
colorWarning: 'var(--color-text)',
|
||||
colorLoading: 'var(--color-text)',
|
||||
textColor: 'var(--color-bg)',
|
||||
textColorInfo: 'var(--color-bg)',
|
||||
textColorSuccess: 'var(--color-bg)',
|
||||
textColorError: 'var(--color-bg)',
|
||||
textColorWarning: 'var(--color-bg)',
|
||||
textColorLoading: 'var(--color-bg)',
|
||||
iconColorInfo: 'var(--color-primary-light)',
|
||||
iconColorSuccess: 'var(--color-success)',
|
||||
iconColorError: 'var(--color-danger)',
|
||||
iconColorWarning: 'var(--color-warning)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
},
|
||||
};
|
||||
|
||||
export const useMessage = createSharedComposable(() => {
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
const { message } = createDiscreteApi(['message'], {
|
||||
configProviderProps: { themeOverrides: toastTheme },
|
||||
});
|
||||
return message;
|
||||
});
|
||||
|
||||
@@ -484,7 +484,11 @@
|
||||
"select": "Select",
|
||||
"selectAll": "Select All",
|
||||
"items": "items",
|
||||
"moreActions": "More actions"
|
||||
"moreActions": "More actions",
|
||||
"copy": "Copy",
|
||||
"clear": "Clear",
|
||||
"toggle_password": "Show or hide password",
|
||||
"empty": "Nothing here yet"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Light",
|
||||
|
||||
@@ -484,7 +484,11 @@
|
||||
"select": "选择",
|
||||
"selectAll": "全选",
|
||||
"items": "项",
|
||||
"moreActions": "更多操作"
|
||||
"moreActions": "更多操作",
|
||||
"copy": "复制",
|
||||
"clear": "清空",
|
||||
"toggle_password": "显示或隐藏密码",
|
||||
"empty": "暂无内容"
|
||||
},
|
||||
"theme": {
|
||||
"light": "浅色",
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface AbSettingProps {
|
||||
css?: string;
|
||||
prop?: any;
|
||||
bottomLine?: boolean;
|
||||
/** 字段说明文字(显示在标签下方) */
|
||||
description?: string;
|
||||
/** 校验错误文案(显示为错误态) */
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export type SettingItem<T> = AbSettingProps & {
|
||||
|
||||
2
webui/types/dts/auto-imports.d.ts
vendored
2
webui/types/dts/auto-imports.d.ts
vendored
@@ -105,6 +105,8 @@ declare global {
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useConfigStore: typeof import('../../src/store/config')['useConfigStore']
|
||||
const useConfirm: typeof import('../../src/hooks/useConfirm')['useConfirm']
|
||||
const useConfirmState: typeof import('../../src/hooks/useConfirm')['useConfirmState']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useDarkMode: typeof import('../../src/hooks/useDarkMode')['useDarkMode']
|
||||
|
||||
19
webui/types/dts/components.d.ts
vendored
19
webui/types/dts/components.d.ts
vendored
@@ -12,21 +12,33 @@ declare module '@vue/runtime-core' {
|
||||
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']
|
||||
AbModal: typeof import('./../../src/components/basic/ab-modal.vue')['default']
|
||||
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']
|
||||
@@ -35,12 +47,19 @@ declare module '@vue/runtime-core' {
|
||||
AbSearchConfirm: typeof import('./../../src/components/search/ab-search-confirm.vue')['default']
|
||||
AbSearchFilters: typeof import('./../../src/components/search/ab-search-filters.vue')['default']
|
||||
AbSearchModal: typeof import('./../../src/components/search/ab-search-modal.vue')['default']
|
||||
AbSegmented: typeof import('./../../src/components/basic/ab-segmented.vue')['default']
|
||||
AbSelect: typeof import('./../../src/components/basic/ab-select.vue')['default']
|
||||
AbSetting: typeof import('./../../src/components/ab-setting.vue')['default']
|
||||
AbSidebar: typeof import('./../../src/components/layout/ab-sidebar.vue')['default']
|
||||
AbSkeleton: typeof import('./../../src/components/basic/ab-skeleton.vue')['default']
|
||||
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']
|
||||
AbTooltip: typeof import('./../../src/components/basic/ab-tooltip.vue')['default']
|
||||
AbTopbar: typeof import('./../../src/components/layout/ab-topbar.vue')['default']
|
||||
AdvancedSection: typeof import('./../../src/components/advanced-section.vue')['default']
|
||||
BangumiFilterField: typeof import('./../../src/components/bangumi-filter-field.vue')['default']
|
||||
|
||||
Reference in New Issue
Block a user