diff --git a/webui/src/App.vue b/webui/src/App.vue
index b2a7a5c4..4d3c1991 100644
--- a/webui/src/App.vue
+++ b/webui/src/App.vue
@@ -147,6 +147,7 @@ const naiveTheme = computed(() => (isDark.value ? darkTheme : null));
+
diff --git a/webui/src/components/ab-container.vue b/webui/src/components/ab-container.vue
index 0b369c3c..7e939614 100644
--- a/webui/src/components/ab-container.vue
+++ b/webui/src/components/ab-container.vue
@@ -2,9 +2,12 @@
withDefaults(
defineProps<{
title: string;
+ /** 去掉 body 内边距(内容自带布局时,如列表/表格贴边) */
+ flush?: boolean;
}>(),
{
title: 'title',
+ flush: false,
}
);
@@ -16,9 +19,13 @@ withDefaults(
-
@@ -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);
}
diff --git a/webui/src/components/ab-fold-panel.vue b/webui/src/components/ab-fold-panel.vue
index fb679675..999f75ad 100644
--- a/webui/src/components/ab-fold-panel.vue
+++ b/webui/src/components/ab-fold-panel.vue
@@ -1,7 +1,9 @@
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webui/src/components/basic/ab-badge.vue b/webui/src/components/basic/ab-badge.vue
new file mode 100644
index 00000000..49fb5f08
--- /dev/null
+++ b/webui/src/components/basic/ab-badge.vue
@@ -0,0 +1,57 @@
+
+
+
+
+ {{ text }}
+
+
+
+
diff --git a/webui/src/components/basic/ab-bottom-sheet.vue b/webui/src/components/basic/ab-bottom-sheet.vue
index c1b13dba..4f856251 100644
--- a/webui/src/components/basic/ab-bottom-sheet.vue
+++ b/webui/src/components/basic/ab-bottom-sheet.vue
@@ -146,6 +146,11 @@ function close() {
+
+
+
@@ -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);
+ }
}
diff --git a/webui/src/components/basic/ab-confirm-host.vue b/webui/src/components/basic/ab-confirm-host.vue
new file mode 100644
index 00000000..0eb9c271
--- /dev/null
+++ b/webui/src/components/basic/ab-confirm-host.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+ {{ state.current.body }}
+
+
+
+
+ {{ state.current?.cancelText ?? $t('common.cancel') }}
+
+
+ {{ state.current?.confirmText ?? $t('common.confirm') }}
+
+
+
+
+
+
diff --git a/webui/src/components/basic/ab-empty.vue b/webui/src/components/basic/ab-empty.vue
new file mode 100644
index 00000000..768feba7
--- /dev/null
+++ b/webui/src/components/basic/ab-empty.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
{{ title }}
+
{{ description }}
+
+
+
+
+
+
+
diff --git a/webui/src/components/basic/ab-field.stories.ts b/webui/src/components/basic/ab-field.stories.ts
new file mode 100644
index 00000000..b29c183e
--- /dev/null
+++ b/webui/src/components/basic/ab-field.stories.ts
@@ -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 = {
+ title: 'basic/ab-field',
+ component: AbField,
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Template: Story = {
+ render: () => ({
+ components: { AbField, AbInput },
+ template: `
+ `,
+ }),
+};
diff --git a/webui/src/components/basic/ab-field.vue b/webui/src/components/basic/ab-field.vue
new file mode 100644
index 00000000..f3cca241
--- /dev/null
+++ b/webui/src/components/basic/ab-field.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
diff --git a/webui/src/components/basic/ab-icon-button.vue b/webui/src/components/basic/ab-icon-button.vue
index 0dc4ed4c..1000e1c9 100644
--- a/webui/src/components/basic/ab-icon-button.vue
+++ b/webui/src/components/basic/ab-icon-button.vue
@@ -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) {
-
+
-
+
diff --git a/webui/src/components/basic/ab-status.stories.ts b/webui/src/components/basic/ab-status.stories.ts
index 5c3feb5c..7f54cd32 100644
--- a/webui/src/components/basic/ab-status.stories.ts
+++ b/webui/src/components/basic/ab-status.stories.ts
@@ -6,12 +6,19 @@ const meta: Meta = {
title: 'basic/ab-status',
component: AbStatus,
tags: ['autodocs'],
+ argTypes: {
+ state: {
+ control: 'select',
+ options: ['running', 'stopped', 'paused', 'degraded'],
+ },
+ },
};
export default meta;
type Story = StoryObj;
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: '',
}),
};
+
+export const AllStates: Story = {
+ render: () => ({
+ components: { AbStatus },
+ template: `
+ `,
+ }),
+};
diff --git a/webui/src/components/basic/ab-status.vue b/webui/src/components/basic/ab-status.vue
index 322d2eac..de2d9278 100644
--- a/webui/src/components/basic/ab-status.vue
+++ b/webui/src/components/basic/ab-status.vue
@@ -1,70 +1,109 @@
-
+
+ {{ label }}
+ {{ detail }}
+
diff --git a/webui/src/components/basic/ab-switch.stories.ts b/webui/src/components/basic/ab-switch.stories.ts
new file mode 100644
index 00000000..1d1db287
--- /dev/null
+++ b/webui/src/components/basic/ab-switch.stories.ts
@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from '@storybook/vue3';
+import { ref } from 'vue';
+
+import AbSwitch from './ab-switch.vue';
+
+const meta: Meta = {
+ title: 'basic/ab-switch',
+ component: AbSwitch,
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Template: Story = {
+ render: () => ({
+ components: { AbSwitch },
+ setup() {
+ const on = ref(true);
+ const off = ref(false);
+ return { on, off };
+ },
+ template: `
+ `,
+ }),
+};
diff --git a/webui/src/components/basic/ab-switch.vue b/webui/src/components/basic/ab-switch.vue
new file mode 100644
index 00000000..cb0bdcfd
--- /dev/null
+++ b/webui/src/components/basic/ab-switch.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
diff --git a/webui/src/components/basic/ab-tag.stories.ts b/webui/src/components/basic/ab-tag.stories.ts
index 69021fd4..8c869297 100644
--- a/webui/src/components/basic/ab-tag.stories.ts
+++ b/webui/src/components/basic/ab-tag.stories.ts
@@ -6,12 +6,19 @@ const meta: Meta = {
title: 'basic/ab-tag',
component: AbTag,
tags: ['autodocs'],
+ argTypes: {
+ type: {
+ control: 'select',
+ options: ['success', 'warning', 'danger', 'info', 'neutral'],
+ },
+ },
};
export default meta;
type Story = StoryObj;
export const Template: Story = {
+ args: { type: 'success', title: 'Airing' },
render: (args) => ({
components: { AbTag },
setup() {
@@ -20,3 +27,19 @@ export const Template: Story = {
template: '',
}),
};
+
+export const AllTypes: Story = {
+ render: () => ({
+ components: { AbTag },
+ template: `
+ `,
+ }),
+};
diff --git a/webui/src/components/basic/ab-tag.vue b/webui/src/components/basic/ab-tag.vue
index 32409419..1c1b850c 100644
--- a/webui/src/components/basic/ab-tag.vue
+++ b/webui/src/components/basic/ab-tag.vue
@@ -1,67 +1,120 @@
-
- {{ title }}
+
+ {{ title }}
+
+
+
diff --git a/webui/src/components/basic/ab-toolbar.vue b/webui/src/components/basic/ab-toolbar.vue
new file mode 100644
index 00000000..7e336f6e
--- /dev/null
+++ b/webui/src/components/basic/ab-toolbar.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
diff --git a/webui/src/components/basic/ab-tooltip.stories.ts b/webui/src/components/basic/ab-tooltip.stories.ts
new file mode 100644
index 00000000..a78157ea
--- /dev/null
+++ b/webui/src/components/basic/ab-tooltip.stories.ts
@@ -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 = {
+ title: 'basic/ab-tooltip',
+ component: AbTooltip,
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Template: Story = {
+ args: { content: 'Refresh all RSS feeds' },
+ render: (args) => ({
+ components: { AbTooltip, AbButton },
+ setup() {
+ return { args };
+ },
+ template: `
+
+ Hover me
+ `,
+ }),
+};
diff --git a/webui/src/components/basic/ab-tooltip.vue b/webui/src/components/basic/ab-tooltip.vue
new file mode 100644
index 00000000..2b4c47f1
--- /dev/null
+++ b/webui/src/components/basic/ab-tooltip.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+ {{ content }}
+
+
diff --git a/webui/src/hooks/useConfirm.ts b/webui/src/hooks/useConfirm.ts
new file mode 100644
index 00000000..39ae4a0b
--- /dev/null
+++ b/webui/src/hooks/useConfirm.ts
@@ -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 {
+ return new Promise((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 };
+}
diff --git a/webui/src/hooks/useMessage.ts b/webui/src/hooks/useMessage.ts
index 1a8738bc..bad2151f 100644
--- a/webui/src/hooks/useMessage.ts
+++ b/webui/src/hooks/useMessage.ts
@@ -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;
});
diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json
index 6688a215..533a2374 100644
--- a/webui/src/i18n/en.json
+++ b/webui/src/i18n/en.json
@@ -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",
diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json
index 499cdb15..155e8d30 100644
--- a/webui/src/i18n/zh-CN.json
+++ b/webui/src/i18n/zh-CN.json
@@ -484,7 +484,11 @@
"select": "选择",
"selectAll": "全选",
"items": "项",
- "moreActions": "更多操作"
+ "moreActions": "更多操作",
+ "copy": "复制",
+ "clear": "清空",
+ "toggle_password": "显示或隐藏密码",
+ "empty": "暂无内容"
},
"theme": {
"light": "浅色",
diff --git a/webui/types/components.ts b/webui/types/components.ts
index 5f3af19d..e922de43 100644
--- a/webui/types/components.ts
+++ b/webui/types/components.ts
@@ -11,6 +11,11 @@ export interface AbSettingProps {
css?: string;
prop?: any;
bottomLine?: boolean;
+ /** 字段说明文字(显示在标签下方) */
+ description?: string;
+ /** 校验错误文案(显示为错误态) */
+ error?: string;
+ required?: boolean;
}
export type SettingItem = AbSettingProps & {
diff --git a/webui/types/dts/auto-imports.d.ts b/webui/types/dts/auto-imports.d.ts
index ebad9a54..a8cba333 100644
--- a/webui/types/dts/auto-imports.d.ts
+++ b/webui/types/dts/auto-imports.d.ts
@@ -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']
diff --git a/webui/types/dts/components.d.ts b/webui/types/dts/components.d.ts
index 15b46209..f3f915f2 100644
--- a/webui/types/dts/components.d.ts
+++ b/webui/types/dts/components.d.ts
@@ -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']