mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-11 14:36:56 +08:00
refactor(webui): complete ab-* migration, retire legacy components
Migrate the remaining surfaces: all seven ab-popup call sites to ab-modal (footer slots, sm sizing), every ab-label to ab-field, all raw ab-input-shortcut inputs to the ab-input component (textareas get local Soft Ink styles), rss mobile list to ab-list with loading skeleton, and de-tint the hand-rolled warning callouts (hairline + ink, per the no-tinted-boxes rule). Wire aria-live into pull-refresh. Delete dead code: ab-popup, ab-adaptive-modal, ab-label, ab-data-list, ab-add, ab-button-multi, ab-image, ab-rule, ab-swipe-container (+ orphaned stories); drop the ab-input/input-error UnoCSS shortcuts. Add stories for the full universal set and document the final 22-component catalog in DESIGN.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
13
DESIGN.md
13
DESIGN.md
@@ -59,6 +59,19 @@ Semantic scale only: `--z-dropdown` 10 · `--z-sticky` 20 · `--z-fixed` 30 · `
|
||||
- Soft Ink component grammar: filled controls on `--color-surface-2` (border only on focus/error); tags are hairline chips with a square semantic mark and ink text; status is a square mark + ink label; tabs underline (no thumb); alerts are hairline outlines led by a bold colored word (no icons, no tinted fills); toasts are inverted ink chips; empty states are left-aligned prose with one action; loading is skeletons, not spinners-in-content.
|
||||
- Every universal component ships a Storybook story and (where it has behavior) a vitest test in `__tests__/`.
|
||||
|
||||
The universal set (`src/components/basic/` unless noted):
|
||||
|
||||
| Group | Components |
|
||||
|---|---|
|
||||
| Actions | `ab-button` (primary/secondary/ghost/danger·sm/md·loading), `ab-icon-button` (required label, 44px touch, ghost/solid/round), `ab-menu` (headlessui dropdown), `ab-split-button` |
|
||||
| Forms | `ab-field` (label/description/error/required + aria wiring via injection), `ab-input` (affixes, clearable, password reveal, error), `ab-select`, `ab-switch`, `ab-segmented` (underline tabs), `ab-setting` (root — field+control settings row) |
|
||||
| Overlays | `ab-modal` (adaptive: dialog ≥ tablet, bottom sheet on mobile; footer slot; sizes sm/md/lg), `useConfirm()` + `ab-confirm-host` (promise confirm, danger variant), `ab-tooltip`, `ab-bottom-sheet` (internal to ab-modal) |
|
||||
| Display | `ab-tag` (semantic marks: success/warning/danger/info/neutral, closable), `ab-status` (running/stopped/paused/degraded + label/detail), `ab-badge` (count/dot, 99+), `ab-container` (root — title/footer/flush card), `ab-fold-panel` (root — accessible, animated), `ab-progress` |
|
||||
| Data & feedback | `ab-list` (selection, loading skeleton, density comfortable/compact, empty slot), `ab-toolbar` (search/filters/actions), `ab-alert` (hairline, bold lead word), toast via `useMessage()` (inverted ink), `ab-empty`, `ab-skeleton` (lines/row/card), `ab-pull-refresh` |
|
||||
| Page chrome | `ab-page-title`, `ab-search`, `ab-status-bar` (root), layout shells |
|
||||
|
||||
Retired (do not reintroduce): `ab-popup`, `ab-adaptive-modal`, `ab-label`, `ab-setting`'s type-switch-only API, `ab-add`, `ab-button-multi`, `ab-data-list`, `ab-image`, `ab-rule`, `ab-swipe-container`, and the `ab-input` UnoCSS attribute shortcut.
|
||||
|
||||
## Voice
|
||||
|
||||
UI copy through i18n only (`src/i18n/en.json` + `zh-CN.json`, keys always added in pairs). Sentence case in English. Labels are nouns, buttons are verbs, errors say what to do next.
|
||||
|
||||
@@ -7,37 +7,28 @@ const { user, update } = useAuth();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-popup
|
||||
v-model:show="show"
|
||||
:title="$t('topbar.profile.pop_title')"
|
||||
css="w-365"
|
||||
>
|
||||
<ab-modal v-model:show="show" size="sm" :title="$t('topbar.profile.pop_title')">
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('topbar.profile.username')">
|
||||
<input
|
||||
<ab-field :label="$t('topbar.profile.username')">
|
||||
<ab-input
|
||||
v-model="user.username"
|
||||
type="text"
|
||||
:placeholder="$t('topbar.profile.username')"
|
||||
ab-input
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="$t('topbar.profile.password')">
|
||||
<input
|
||||
<ab-field :label="$t('topbar.profile.password')">
|
||||
<ab-input
|
||||
v-model="user.password"
|
||||
type="password"
|
||||
:placeholder="$t('topbar.profile.password')"
|
||||
ab-input
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<div line></div>
|
||||
|
||||
<div flex="~ justify-end">
|
||||
<ab-button variant="primary" size="sm" @click="update">{{
|
||||
$t('topbar.profile.update_btn')
|
||||
}}</ab-button>
|
||||
</div>
|
||||
</ab-field>
|
||||
</div>
|
||||
</ab-popup>
|
||||
|
||||
<template #footer>
|
||||
<ab-button variant="primary" size="sm" @click="update">{{
|
||||
$t('topbar.profile.update_btn')
|
||||
}}</ab-button>
|
||||
</template>
|
||||
</ab-modal>
|
||||
</template>
|
||||
|
||||
@@ -190,11 +190,11 @@ function emitUnarchive() {
|
||||
|
||||
<template>
|
||||
<!-- Enable deleted rule dialog -->
|
||||
<ab-popup
|
||||
<ab-modal
|
||||
v-if="rule.deleted"
|
||||
v-model:show="show"
|
||||
size="sm"
|
||||
:title="$t('homepage.rule.enable_rule')"
|
||||
css="w-300 max-w-[90vw]"
|
||||
>
|
||||
<div>{{ $t('homepage.rule.enable_hit') }}</div>
|
||||
<div line my-8></div>
|
||||
@@ -206,7 +206,7 @@ function emitUnarchive() {
|
||||
{{ $t('homepage.rule.no_btn') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</ab-modal>
|
||||
|
||||
<!-- Main edit modal -->
|
||||
<Teleport v-else to="body">
|
||||
@@ -314,12 +314,12 @@ function emitUnarchive() {
|
||||
<label class="weekday-label">{{
|
||||
$t('homepage.rule.preferred_group')
|
||||
}}</label>
|
||||
<input
|
||||
v-model="localRule.preferred_group"
|
||||
ab-input
|
||||
<ab-input
|
||||
:model-value="localRule.preferred_group ?? ''"
|
||||
type="text"
|
||||
class="preferred-input"
|
||||
placeholder="ANi"
|
||||
@update:model-value="localRule.preferred_group = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorPicture } from '@icon-park/vue-next';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
src?: string | null;
|
||||
aspectRatio?: number;
|
||||
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
|
||||
}>(),
|
||||
{
|
||||
objectFit: 'cover',
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div rel>
|
||||
<template v-if="aspectRatio">
|
||||
<div
|
||||
w-full
|
||||
:style="{ paddingBottom: `calc(${1 / aspectRatio} * 100%)` }"
|
||||
></div>
|
||||
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
alt="poster"
|
||||
loading="lazy"
|
||||
abs
|
||||
top-0
|
||||
left-0
|
||||
:style="{ objectFit }"
|
||||
wh-full
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
alt="poster"
|
||||
loading="lazy"
|
||||
:style="{ objectFit }"
|
||||
wh-full
|
||||
/>
|
||||
|
||||
<div v-else wh-full f-cer border="1 white">
|
||||
<ErrorPicture theme="outline" size="24" fill="#333" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string | (() => string);
|
||||
/** 关联的表单控件 id:提供时渲染真正的 <label for> */
|
||||
forId?: string;
|
||||
/** 文本节点 id,供 aria-labelledby 引用(无法用 for 关联的组件) */
|
||||
labelId?: string;
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
forId: undefined,
|
||||
labelId: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const abLabel = computed(() => {
|
||||
if (typeof props.label === 'function') return props.label();
|
||||
else return props.label;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="label-row">
|
||||
<label v-if="forId" :id="labelId" class="label-text" :for="forId">
|
||||
{{ abLabel }}
|
||||
</label>
|
||||
<span v-else :id="labelId" class="label-text">{{ abLabel }}</span>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.label-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
|
||||
@include forTablet {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Make inputs full-width on mobile
|
||||
:deep(input[ab-input]),
|
||||
:deep(.n-select),
|
||||
:deep(.n-dynamic-tags) {
|
||||
width: 100%;
|
||||
|
||||
@include forTablet {
|
||||
width: auto;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
transition: color var(--transition-normal);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,129 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
maskClick?: boolean;
|
||||
css?: string;
|
||||
}>(),
|
||||
{
|
||||
title: 'title',
|
||||
maskClick: true,
|
||||
css: '',
|
||||
}
|
||||
);
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
|
||||
function close() {
|
||||
if (props.maskClick) {
|
||||
show.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Mobile: bottom sheet -->
|
||||
<ab-bottom-sheet
|
||||
v-if="isMobile"
|
||||
:show="show"
|
||||
:title="title"
|
||||
:closeable="maskClick"
|
||||
@update:show="show = $event"
|
||||
>
|
||||
<slot></slot>
|
||||
</ab-bottom-sheet>
|
||||
|
||||
<!-- Desktop/Tablet: centered dialog -->
|
||||
<TransitionRoot v-else appear :show="show" as="template">
|
||||
<Dialog as="div" class="popup-dialog" @close="close">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="duration-200 ease-in"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="popup-backdrop" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="popup-wrapper">
|
||||
<div class="popup-center">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0 scale-95"
|
||||
enter-to="opacity-100 scale-100"
|
||||
leave="duration-200 ease-in"
|
||||
leave-from="opacity-100 scale-100"
|
||||
leave-to="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel>
|
||||
<ab-container :title="title" :class="[css]">
|
||||
<slot></slot>
|
||||
</ab-container>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-dialog {
|
||||
position: relative;
|
||||
z-index: var(--z-modal, 50);
|
||||
}
|
||||
|
||||
.popup-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal-backdrop, 40);
|
||||
background: rgba(108, 74, 182, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.popup-wrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal, 50);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.popup-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.container-card) {
|
||||
border: 1px solid var(--color-primary);
|
||||
box-shadow: 0 8px 32px rgba(108, 74, 182, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.container-header) {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-bottom: none;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
:deep(.container-body) {
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,138 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
import type { SettingItem } from '#/components';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
|
||||
const rule = defineModel<BangumiRule>('rule', {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const offsetLoading = ref(false);
|
||||
const offsetReason = ref('');
|
||||
|
||||
async function autoDetectOffset() {
|
||||
if (!rule.value.id) return;
|
||||
offsetLoading.value = true;
|
||||
offsetReason.value = '';
|
||||
try {
|
||||
const result = await apiBangumi.suggestOffset(rule.value.id);
|
||||
rule.value.episode_offset = result.suggested_offset;
|
||||
offsetReason.value = result.reason;
|
||||
} catch (e) {
|
||||
console.error('Failed to detect offset:', e);
|
||||
} finally {
|
||||
offsetLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const items: SettingItem<BangumiRule>[] = [
|
||||
{
|
||||
configKey: 'official_title',
|
||||
label: () => t('homepage.rule.official_title'),
|
||||
type: 'input',
|
||||
prop: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
{
|
||||
configKey: 'year',
|
||||
label: () => t('homepage.rule.year'),
|
||||
type: 'input',
|
||||
css: 'w-72',
|
||||
prop: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
{
|
||||
configKey: 'season',
|
||||
label: () => t('homepage.rule.season'),
|
||||
type: 'input',
|
||||
css: 'w-72',
|
||||
prop: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
{
|
||||
configKey: 'filter',
|
||||
label: () => t('homepage.rule.exclude'),
|
||||
type: 'dynamic-tags',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rule-form">
|
||||
<ab-setting
|
||||
v-for="i in items"
|
||||
:key="i.configKey"
|
||||
v-bind="i"
|
||||
v-model:data="rule[i.configKey]"
|
||||
></ab-setting>
|
||||
|
||||
<!-- Offset field with auto-detect button -->
|
||||
<ab-label :label="() => $t('homepage.rule.offset')">
|
||||
<div class="offset-controls">
|
||||
<input
|
||||
v-model.number="rule.episode_offset"
|
||||
type="number"
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="offsetLoading"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
{{ $t('homepage.rule.auto_detect') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</ab-label>
|
||||
|
||||
<div v-if="offsetReason" class="offset-reason">{{ offsetReason }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rule-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.offset-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
@include forTablet {
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
:deep(.ab-button) {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.offset-input {
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include forMobile {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.offset-reason {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
padding-left: 2px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
</style>
|
||||
@@ -24,10 +24,9 @@ const proxyValue = computed({
|
||||
<div class="advanced-row">
|
||||
<label class="advanced-label">{{ label }}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<input
|
||||
v-model.number="proxyValue"
|
||||
<ab-input
|
||||
v-model="proxyValue"
|
||||
type="number"
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
<slot name="action" />
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
maskClick?: boolean;
|
||||
css?: string;
|
||||
maxHeight?: string;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
maskClick: true,
|
||||
css: '',
|
||||
maxHeight: '85dvh',
|
||||
}
|
||||
);
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Mobile: Bottom sheet -->
|
||||
<ab-bottom-sheet
|
||||
v-if="isMobile"
|
||||
:show="show"
|
||||
:title="title"
|
||||
:closeable="maskClick"
|
||||
:max-height="maxHeight"
|
||||
@update:show="show = $event"
|
||||
>
|
||||
<slot />
|
||||
</ab-bottom-sheet>
|
||||
|
||||
<!-- Desktop/Tablet: Centered popup -->
|
||||
<ab-popup
|
||||
v-else
|
||||
v-model:show="show"
|
||||
:title="title"
|
||||
:mask-click="maskClick"
|
||||
:css="css"
|
||||
>
|
||||
<slot />
|
||||
</ab-popup>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbAdd from './ab-add.vue';
|
||||
|
||||
const meta: Meta<typeof AbAdd> = {
|
||||
title: 'basic/ab-add',
|
||||
component: AbAdd,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbAdd>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: (args) => ({
|
||||
components: { AbAdd },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<ab-add v-bind="args"></ab-add>',
|
||||
}),
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
round?: boolean;
|
||||
type?: 'large' | 'medium' | 'small';
|
||||
}>(),
|
||||
{
|
||||
round: false,
|
||||
type: 'large',
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits(['click']);
|
||||
|
||||
const buttonSize = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'large':
|
||||
return 'add-btn--large';
|
||||
case 'medium':
|
||||
return 'add-btn--medium';
|
||||
case 'small':
|
||||
return 'add-btn--small';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="add-btn"
|
||||
:class="[buttonSize, round && 'add-btn--round']"
|
||||
aria-label="Add"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" class="add-btn-icon">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
&--round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&--large {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
.add-btn-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
.add-btn-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
|
||||
.add-btn-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&--round.add-btn--large {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&--round.add-btn--medium {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&--round.add-btn--small {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
30
webui/src/components/basic/ab-alert.stories.ts
Normal file
30
webui/src/components/basic/ab-alert.stories.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbAlert from './ab-alert.vue';
|
||||
|
||||
const meta: Meta<typeof AbAlert> = {
|
||||
title: 'basic/ab-alert',
|
||||
component: AbAlert,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbAlert>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbAlert },
|
||||
template: `
|
||||
<div style="display:flex;flex-direction:column;gap:10px;max-width:520px">
|
||||
<ab-alert type="info" title="Heads up.">
|
||||
Version 3.3 moves renaming to the server — no action needed.
|
||||
</ab-alert>
|
||||
<ab-alert type="warning" title="Feed unreachable.">
|
||||
Mikan timed out twice — check the proxy settings.
|
||||
</ab-alert>
|
||||
<ab-alert type="danger" title="Parse failed." closable>
|
||||
The torrent title could not be parsed.
|
||||
</ab-alert>
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
25
webui/src/components/basic/ab-badge.stories.ts
Normal file
25
webui/src/components/basic/ab-badge.stories.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbBadge from './ab-badge.vue';
|
||||
|
||||
const meta: Meta<typeof AbBadge> = {
|
||||
title: 'basic/ab-badge',
|
||||
component: AbBadge,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbBadge>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbBadge },
|
||||
template: `
|
||||
<div style="display:flex;gap:14px;align-items:center">
|
||||
<ab-badge :count="3" />
|
||||
<ab-badge :count="120" />
|
||||
<ab-badge dot />
|
||||
<ab-badge :count="0" />
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import type {Meta, StoryObj} from '@storybook/vue3';
|
||||
|
||||
import AbButtonMulti from './ab-button-multi.vue';
|
||||
|
||||
const meta: Meta<typeof AbButtonMulti> = {
|
||||
title: 'basic/ab-button-multi',
|
||||
component: AbButtonMulti,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: {type: 'select'},
|
||||
options: ['primary', 'warn'],
|
||||
},
|
||||
size: {
|
||||
control: {type: 'select'},
|
||||
options: ['big', 'normal', 'small'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbButtonMulti>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: (args) => ({
|
||||
components: {AbButtonMulti},
|
||||
setup() {
|
||||
return {args};
|
||||
},
|
||||
template: '<ab-button-multi v-bind="args">button</ab-button-multi>',
|
||||
}),
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { NSpin } from 'naive-ui';
|
||||
import { Down } from '@icon-park/vue-next';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'primary' | 'warn';
|
||||
size?: 'big' | 'normal' | 'small';
|
||||
link?: string | null;
|
||||
loading?: boolean;
|
||||
selections: string[];
|
||||
}>(),
|
||||
{
|
||||
type: 'primary',
|
||||
size: 'normal',
|
||||
link: null,
|
||||
loading: false,
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits(['click']);
|
||||
|
||||
const selected = ref<string>(props.selections[0]);
|
||||
const showSelections = ref<boolean>(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="btn-multi-container">
|
||||
<div class="btn-multi" :class="[`btn-multi--${size}`, `btn-multi--${type}`]">
|
||||
<Component
|
||||
:is="link !== null ? 'a' : 'button'"
|
||||
:href="link"
|
||||
class="btn-multi-main"
|
||||
@click="$emit('click', selected)"
|
||||
>
|
||||
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
|
||||
<div class="btn-multi-label">{{ selected }}</div>
|
||||
</NSpin>
|
||||
</Component>
|
||||
<div
|
||||
class="btn-multi-arrow"
|
||||
@click="() => (showSelections = !showSelections)"
|
||||
>
|
||||
<Down fill="white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showSelections"
|
||||
class="btn-multi-dropdown"
|
||||
:class="[`btn-multi--${size}`, `btn-multi--${type}`]"
|
||||
>
|
||||
<div
|
||||
v-for="selection in selections"
|
||||
:key="selection"
|
||||
class="btn-multi-option"
|
||||
@click="() => { selected = selection; showSelections = false; }"
|
||||
>
|
||||
{{ selection }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.btn-multi-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.btn-multi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
|
||||
&--big {
|
||||
border-radius: var(--radius-md);
|
||||
width: 276px;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&--normal {
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 100px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&--small {
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
.btn-multi-main,
|
||||
.btn-multi-arrow {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
.btn-multi-main:hover,
|
||||
.btn-multi-arrow:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&--warn {
|
||||
.btn-multi-main,
|
||||
.btn-multi-arrow {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
.btn-multi-main:hover,
|
||||
.btn-multi-arrow:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-multi-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding-left: 12px;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-multi-label {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.btn-multi-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-multi-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
margin-bottom: 4px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 100%;
|
||||
|
||||
&.btn-multi--primary {
|
||||
.btn-multi-option {
|
||||
background: var(--color-primary);
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-multi--warn {
|
||||
.btn-multi-option {
|
||||
background: var(--color-danger);
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-multi-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: #fff;
|
||||
font-size: inherit;
|
||||
transition: background-color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,244 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
export interface DataListColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
render?: (row: Record<string, unknown>) => string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
type DataItem = Record<string, any>;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: DataItem[];
|
||||
columns: DataListColumn[];
|
||||
selectable?: boolean;
|
||||
keyField?: string;
|
||||
/** Controlled selection — keeps the checkboxes in sync when the parent
|
||||
* resets its selection (e.g. after a bulk action). */
|
||||
selected?: unknown[];
|
||||
}>(),
|
||||
{
|
||||
selectable: false,
|
||||
keyField: 'id',
|
||||
selected: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', keys: unknown[]): void;
|
||||
(e: 'action', action: string, item: DataItem): void;
|
||||
(e: 'item-click', item: DataItem): void;
|
||||
}>();
|
||||
|
||||
const selectedKeys = ref<Set<unknown>>(new Set(props.selected ?? []));
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
(val) => {
|
||||
if (val) selectedKeys.value = new Set(val);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const visibleColumns = computed(() =>
|
||||
props.columns.filter((col) => !col.hidden)
|
||||
);
|
||||
|
||||
function toggleSelect(key: unknown) {
|
||||
if (selectedKeys.value.has(key)) {
|
||||
selectedKeys.value.delete(key);
|
||||
} else {
|
||||
selectedKeys.value.add(key);
|
||||
}
|
||||
emit('select', Array.from(selectedKeys.value));
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selectedKeys.value.size === props.items.length) {
|
||||
selectedKeys.value.clear();
|
||||
} else {
|
||||
props.items.forEach((item) => selectedKeys.value.add(item[props.keyField]));
|
||||
}
|
||||
emit('select', Array.from(selectedKeys.value));
|
||||
}
|
||||
|
||||
function getCellValue(item: DataItem, column: DataListColumn): string {
|
||||
if (column.render) {
|
||||
return column.render(item);
|
||||
}
|
||||
return item[column.key] ?? '';
|
||||
}
|
||||
|
||||
defineExpose({ selectedKeys, toggleSelectAll });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ab-data-list">
|
||||
<!-- Select all header (when selectable) -->
|
||||
<div v-if="selectable && items.length > 0" class="ab-data-list__header">
|
||||
<label class="ab-data-list__select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedKeys.size === items.length && items.length > 0"
|
||||
:indeterminate="
|
||||
selectedKeys.size > 0 && selectedKeys.size < items.length
|
||||
"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
<span>{{ $t('common.selectAll') }}</span>
|
||||
</label>
|
||||
<span class="ab-data-list__count"
|
||||
>{{ items.length }} {{ $t('common.items') }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item[keyField]"
|
||||
class="ab-data-list__item"
|
||||
@click="emit('item-click', item)"
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
<div v-if="selectable" class="ab-data-list__checkbox" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedKeys.has(item[keyField])"
|
||||
@change="toggleSelect(item[keyField])"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="ab-data-list__card">
|
||||
<slot name="item" :item="item" :columns="visibleColumns">
|
||||
<!-- Default: key-value pairs -->
|
||||
<div
|
||||
v-for="col in visibleColumns"
|
||||
:key="col.key"
|
||||
class="ab-data-list__field"
|
||||
>
|
||||
<span class="ab-data-list__label">{{ col.title }}</span>
|
||||
<span class="ab-data-list__value">{{
|
||||
getCellValue(item, col)
|
||||
}}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="items.length === 0" class="ab-data-list__empty">
|
||||
<slot name="empty">
|
||||
<span>No data</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-data-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
&__select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
|
||||
input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
webui/src/components/basic/ab-empty.stories.ts
Normal file
27
webui/src/components/basic/ab-empty.stories.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbEmpty from './ab-empty.vue';
|
||||
|
||||
const meta: Meta<typeof AbEmpty> = {
|
||||
title: 'basic/ab-empty',
|
||||
component: AbEmpty,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbEmpty>;
|
||||
|
||||
export const Template: Story = {
|
||||
args: {
|
||||
title: 'No RSS rules yet',
|
||||
description:
|
||||
'Add a feed and AutoBangumi starts organizing new episodes automatically.',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AbEmpty },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<ab-empty v-bind="args" />',
|
||||
}),
|
||||
};
|
||||
@@ -16,6 +16,7 @@ const props = withDefaults(
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
maxlength?: number;
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
@@ -27,6 +28,7 @@ const props = withDefaults(
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
step: undefined,
|
||||
maxlength: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -74,6 +76,7 @@ function clear() {
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:maxlength="maxlength"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-describedby="field?.describedBy.value"
|
||||
:aria-invalid="invalid || undefined"
|
||||
|
||||
35
webui/src/components/basic/ab-list.stories.ts
Normal file
35
webui/src/components/basic/ab-list.stories.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbList from './ab-list.vue';
|
||||
|
||||
const meta: Meta<typeof AbList> = {
|
||||
title: 'basic/ab-list',
|
||||
component: AbList,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbList>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbList },
|
||||
setup() {
|
||||
const items = [
|
||||
{ id: 1, name: '葬送的芙莉莲 第二季', meta: 'Mikan · E08' },
|
||||
{ id: 2, name: 'Re:从零开始的异世界生活', meta: 'Nyaa · E21' },
|
||||
{ id: 3, name: '怪兽8号', meta: 'DMHY · E12' },
|
||||
];
|
||||
return { items };
|
||||
},
|
||||
template: `
|
||||
<ab-list :items="items" selectable :selected="[1]">
|
||||
<template #row="{ item }">
|
||||
<div style="display:flex;flex-direction:column;min-width:0">
|
||||
<b>{{ item.name }}</b>
|
||||
<small style="color:var(--color-text-secondary)">{{ item.meta }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</ab-list>`,
|
||||
}),
|
||||
};
|
||||
@@ -273,7 +273,7 @@ function handleCancel() {
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--color-warning-bg);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -371,8 +371,8 @@ function handleCancel() {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--color-warning-bg);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
23
webui/src/components/basic/ab-progress.stories.ts
Normal file
23
webui/src/components/basic/ab-progress.stories.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbProgress from './ab-progress.vue';
|
||||
|
||||
const meta: Meta<typeof AbProgress> = {
|
||||
title: 'basic/ab-progress',
|
||||
component: AbProgress,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbProgress>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbProgress },
|
||||
template: `
|
||||
<div style="display:flex;flex-direction:column;gap:14px;max-width:360px">
|
||||
<ab-progress :value="72" label="72%" />
|
||||
<ab-progress :value="31" label="31%" state="error" />
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
@@ -94,7 +94,13 @@ function onTouchEnd() {
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<!-- Pull indicator -->
|
||||
<div class="ab-pull-refresh__indicator" :style="{ opacity: indicatorOpacity }">
|
||||
<div
|
||||
class="ab-pull-refresh__indicator"
|
||||
:style="{ opacity: indicatorOpacity }"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
:aria-label="loading ? $t('common.loading') : undefined"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="ab-pull-refresh__spinner"
|
||||
|
||||
23
webui/src/components/basic/ab-skeleton.stories.ts
Normal file
23
webui/src/components/basic/ab-skeleton.stories.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbSkeleton from './ab-skeleton.vue';
|
||||
|
||||
const meta: Meta<typeof AbSkeleton> = {
|
||||
title: 'basic/ab-skeleton',
|
||||
component: AbSkeleton,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbSkeleton>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbSkeleton },
|
||||
template: `
|
||||
<div style="display:flex;flex-direction:column;gap:24px;max-width:420px">
|
||||
<ab-skeleton preset="lines" />
|
||||
<ab-skeleton preset="row" :count="2" />
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
@@ -1,149 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: number;
|
||||
showDots?: boolean;
|
||||
itemCount?: number;
|
||||
}>(),
|
||||
{
|
||||
modelValue: 0,
|
||||
showDots: true,
|
||||
itemCount: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', index: number): void;
|
||||
(e: 'change', index: number): void;
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const currentIndex = ref(props.modelValue);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
currentIndex.value = val;
|
||||
scrollToIndex(val);
|
||||
}
|
||||
);
|
||||
|
||||
function scrollToIndex(index: number) {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
const children = container.children;
|
||||
if (children[index]) {
|
||||
(children[index] as HTMLElement).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'start',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const itemWidth = container.clientWidth;
|
||||
const newIndex = Math.round(scrollLeft / itemWidth);
|
||||
|
||||
if (newIndex !== currentIndex.value) {
|
||||
currentIndex.value = newIndex;
|
||||
emit('update:modelValue', newIndex);
|
||||
emit('change', newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function goTo(index: number) {
|
||||
currentIndex.value = index;
|
||||
emit('update:modelValue', index);
|
||||
emit('change', index);
|
||||
scrollToIndex(index);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue > 0) {
|
||||
nextTick(() => scrollToIndex(props.modelValue));
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ goTo });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ab-swipe-container">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="ab-swipe-container__track"
|
||||
@scroll.passive="onScroll"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Pagination dots -->
|
||||
<div v-if="showDots && itemCount > 1" class="ab-swipe-container__dots">
|
||||
<button
|
||||
v-for="i in itemCount"
|
||||
:key="i"
|
||||
class="ab-swipe-container__dot"
|
||||
:class="{ 'ab-swipe-container__dot--active': currentIndex === i - 1 }"
|
||||
:aria-label="`Go to slide ${i}`"
|
||||
@click="goTo(i - 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-swipe-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&__track {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
// Hide scrollbar
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> * {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
&__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--color-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background var(--transition-fast), transform var(--transition-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
webui/src/components/basic/ab-toolbar.stories.ts
Normal file
27
webui/src/components/basic/ab-toolbar.stories.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbToolbar from './ab-toolbar.vue';
|
||||
|
||||
const meta: Meta<typeof AbToolbar> = {
|
||||
title: 'basic/ab-toolbar',
|
||||
component: AbToolbar,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbToolbar>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: () => ({
|
||||
components: { AbToolbar },
|
||||
template: `
|
||||
<ab-toolbar>
|
||||
<template #search>
|
||||
<input style="width:100%;height:32px" placeholder="Search…" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<button>Add RSS</button>
|
||||
</template>
|
||||
</ab-toolbar>`,
|
||||
}),
|
||||
};
|
||||
@@ -105,9 +105,9 @@ function relativeTime(iso: string): string {
|
||||
{{ $t('notifications.mark_all_read') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
v-if="messages.length > 0"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
v-if="messages.length > 0"
|
||||
class="notification-action"
|
||||
@click="onClearClick"
|
||||
>
|
||||
|
||||
@@ -268,10 +268,9 @@ function handleConfirm() {
|
||||
$t('homepage.rule.season_offset')
|
||||
}}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<input
|
||||
v-model.number="localBangumi.season_offset"
|
||||
<ab-input
|
||||
v-model="localBangumi.season_offset"
|
||||
type="number"
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -283,10 +282,9 @@ function handleConfirm() {
|
||||
$t('homepage.rule.episode_offset')
|
||||
}}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<input
|
||||
v-model.number="localBangumi.episode_offset"
|
||||
<ab-input
|
||||
v-model="localBangumi.episode_offset"
|
||||
type="number"
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -251,14 +251,14 @@ const tuningItems = computed(() => [
|
||||
|
||||
<transition name="slide-fade">
|
||||
<div v-if="llm.enable" class="llm-config">
|
||||
<ab-label :label="() => t('config.llm_set.provider')">
|
||||
<ab-field :label="() => t('config.llm_set.provider')">
|
||||
<NSelect
|
||||
v-model:value="llm.provider"
|
||||
class="model-select"
|
||||
:options="providerOptions"
|
||||
:aria-label="t('config.llm_set.provider')"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-setting
|
||||
v-model:data="llm.mode"
|
||||
@@ -298,7 +298,7 @@ const tuningItems = computed(() => [
|
||||
:prop="{ type: 'password', placeholder: 'sk-...' }"
|
||||
/>
|
||||
|
||||
<ab-label :label="() => t('config.llm_set.model')">
|
||||
<ab-field :label="() => t('config.llm_set.model')">
|
||||
<NSelect
|
||||
v-model:value="model"
|
||||
class="model-select"
|
||||
@@ -310,7 +310,7 @@ const tuningItems = computed(() => [
|
||||
:aria-label="t('config.llm_set.model')"
|
||||
@update:show="onModelDropdownShow"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-setting
|
||||
v-if="selected?.needs_base_url"
|
||||
@@ -462,13 +462,13 @@ const tuningItems = computed(() => [
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-warning-bg, #fef9ed);
|
||||
border: 1px solid var(--color-warning-border, #fde68a);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.llm-risk-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-warning-text, #92400e);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.llm-risk-actions {
|
||||
|
||||
@@ -343,40 +343,38 @@ function getFieldsForType(type: string) {
|
||||
</div>
|
||||
|
||||
<!-- Add Dialog -->
|
||||
<ab-popup
|
||||
<ab-modal
|
||||
v-model:show="showAddDialog"
|
||||
size="sm"
|
||||
:title="$t('config.notification_set.add_provider')"
|
||||
css="w-400"
|
||||
>
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('config.notification_set.type')">
|
||||
<ab-field :label="$t('config.notification_set.type')">
|
||||
<NSelect
|
||||
v-model:value="newProvider.type"
|
||||
:options="providerTypes"
|
||||
class="provider-type-select"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label
|
||||
<ab-field
|
||||
v-for="field in getFieldsForType(newProvider.type)"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
>
|
||||
<input
|
||||
<ab-input
|
||||
v-if="field.key !== 'template'"
|
||||
v-model="(newProvider as any)[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
ab-input
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
v-model="(newProvider as any)[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
ab-input
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<div
|
||||
v-if="testResult"
|
||||
@@ -413,44 +411,42 @@ function getFieldsForType(type: string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</ab-modal>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<ab-popup
|
||||
<ab-modal
|
||||
v-model:show="showEditDialog"
|
||||
size="sm"
|
||||
:title="$t('config.notification_set.edit_provider')"
|
||||
css="w-400"
|
||||
>
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('config.notification_set.type')">
|
||||
<ab-field :label="$t('config.notification_set.type')">
|
||||
<NSelect
|
||||
v-model:value="newProvider.type"
|
||||
:options="providerTypes"
|
||||
disabled
|
||||
class="provider-type-select"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label
|
||||
<ab-field
|
||||
v-for="field in getFieldsForType(newProvider.type)"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
>
|
||||
<input
|
||||
<ab-input
|
||||
v-if="field.key !== 'template'"
|
||||
v-model="(newProvider as any)[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
ab-input
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
v-model="(newProvider as any)[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
ab-input
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<div
|
||||
v-if="testResult"
|
||||
@@ -487,7 +483,7 @@ function getFieldsForType(type: string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</ab-modal>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
|
||||
@@ -565,10 +561,29 @@ function getFieldsForType(type: string) {
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
// Soft Ink 填充式多行输入(ab-input 组件暂不支持 textarea)
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
transition: border-color var(--transition-fast),
|
||||
background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
|
||||
&:focus {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
@include forTablet {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.test-result {
|
||||
|
||||
@@ -146,22 +146,20 @@ function formatDate(dateString: string | null): string {
|
||||
</div>
|
||||
|
||||
<!-- 添加对话框 -->
|
||||
<ab-popup
|
||||
<ab-modal
|
||||
v-model:show="showAddDialog"
|
||||
size="sm"
|
||||
:title="$t('passkey.register_title')"
|
||||
css="w-365"
|
||||
>
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('passkey.device_name')">
|
||||
<input
|
||||
<ab-field :label="$t('passkey.device_name')">
|
||||
<ab-input
|
||||
v-model="deviceName"
|
||||
type="text"
|
||||
:placeholder="$t('passkey.device_name_placeholder')"
|
||||
ab-input
|
||||
maxlength="64"
|
||||
:maxlength="64"
|
||||
@keyup.enter="handleAdd"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<div text-14 text-gray-500>
|
||||
{{ $t('passkey.register_hint') }}
|
||||
@@ -183,6 +181,6 @@ function formatDate(dateString: string | null): string {
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</ab-modal>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
|
||||
@@ -218,31 +218,28 @@ function validateUrl(url: string): boolean {
|
||||
</div>
|
||||
|
||||
<!-- Add dialog -->
|
||||
<ab-popup
|
||||
<ab-modal
|
||||
v-model:show="showAddDialog"
|
||||
size="sm"
|
||||
:title="$t('config.search_provider_set.add_title')"
|
||||
css="w-400"
|
||||
>
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('config.search_provider_set.name')">
|
||||
<input
|
||||
<ab-field :label="$t('config.search_provider_set.name')">
|
||||
<ab-input
|
||||
v-model="formName"
|
||||
type="text"
|
||||
:placeholder="$t('config.search_provider_set.name_placeholder')"
|
||||
ab-input
|
||||
maxlength="32"
|
||||
:maxlength="32"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="$t('config.search_provider_set.url')">
|
||||
<input
|
||||
<ab-field :label="$t('config.search_provider_set.url')">
|
||||
<ab-input
|
||||
v-model="formUrl"
|
||||
type="text"
|
||||
:placeholder="$t('config.search_provider_set.url_placeholder')"
|
||||
ab-input
|
||||
@keyup.enter="handleAdd"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
|
||||
{{ $t('config.search_provider_set.url_missing_placeholder') }}
|
||||
@@ -266,38 +263,35 @@ function validateUrl(url: string): boolean {
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</ab-modal>
|
||||
|
||||
<!-- Edit dialog -->
|
||||
<ab-popup
|
||||
<ab-modal
|
||||
v-model:show="showEditDialog"
|
||||
size="sm"
|
||||
:title="$t('config.search_provider_set.edit_title')"
|
||||
css="w-400"
|
||||
>
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('config.search_provider_set.name')">
|
||||
<input
|
||||
<ab-field :label="$t('config.search_provider_set.name')">
|
||||
<ab-input
|
||||
v-model="formName"
|
||||
type="text"
|
||||
:placeholder="$t('config.search_provider_set.name_placeholder')"
|
||||
ab-input
|
||||
maxlength="32"
|
||||
:maxlength="32"
|
||||
:disabled="
|
||||
editingProvider !== null &&
|
||||
isDefaultProvider(editingProvider.name)
|
||||
"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="$t('config.search_provider_set.url')">
|
||||
<input
|
||||
<ab-field :label="$t('config.search_provider_set.url')">
|
||||
<ab-input
|
||||
v-model="formUrl"
|
||||
type="text"
|
||||
:placeholder="$t('config.search_provider_set.url_placeholder')"
|
||||
ab-input
|
||||
@keyup.enter="handleEdit"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
|
||||
{{ $t('config.search_provider_set.url_missing_placeholder') }}
|
||||
@@ -321,7 +315,7 @@ function validateUrl(url: string): boolean {
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</ab-modal>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -34,30 +34,30 @@ const passwordError = computed(() => {
|
||||
<p class="step-subtitle">{{ t('setup.account.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('setup.account.username')">
|
||||
<ab-field :label="t('setup.account.username')">
|
||||
<input
|
||||
v-model="accountData.username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="t('setup.account.password')">
|
||||
<ab-field :label="t('setup.account.password')">
|
||||
<input
|
||||
v-model="accountData.password"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="t('setup.account.confirm_password')">
|
||||
<ab-field :label="t('setup.account.confirm_password')">
|
||||
<input
|
||||
v-model="accountData.confirmPassword"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<p v-if="passwordError" class="error-text">{{ passwordError }}</p>
|
||||
</div>
|
||||
|
||||
@@ -82,57 +82,57 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
|
||||
<p class="step-subtitle">{{ t('setup.downloader.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('config.downloader_set.type')">
|
||||
<ab-field :label="t('config.downloader_set.type')">
|
||||
<NSelect
|
||||
v-model:value="downloaderData.type"
|
||||
:options="downloaderTypeOptions"
|
||||
:aria-label="t('config.downloader_set.type')"
|
||||
class="type-select"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<p v-if="downloaderData.type === 'aria2'" class="aria2-hint">
|
||||
{{ t('config.downloader_set.aria2_hint') }}
|
||||
</p>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.host')">
|
||||
<ab-field :label="t('config.downloader_set.host')">
|
||||
<input
|
||||
v-model="downloaderData.host"
|
||||
type="text"
|
||||
placeholder="172.17.0.1:8080"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.username')">
|
||||
<ab-field :label="t('config.downloader_set.username')">
|
||||
<input
|
||||
v-model="downloaderData.username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.password')">
|
||||
<ab-field :label="t('config.downloader_set.password')">
|
||||
<input
|
||||
v-model="downloaderData.password"
|
||||
type="password"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.path')">
|
||||
<ab-field :label="t('config.downloader_set.path')">
|
||||
<input
|
||||
v-model="downloaderData.path"
|
||||
type="text"
|
||||
placeholder="/downloads/Bangumi"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.ssl')">
|
||||
<ab-field :label="t('config.downloader_set.ssl')">
|
||||
<NSwitch v-model:value="downloaderData.ssl" />
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
|
||||
@@ -62,29 +62,29 @@ const canTest = computed(() => {
|
||||
<p class="step-subtitle">{{ t('setup.notification.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('config.notification_set.type')">
|
||||
<ab-field :label="t('config.notification_set.type')">
|
||||
<NSelect
|
||||
v-model:value="notificationData.type"
|
||||
:options="notificationTypes"
|
||||
size="small"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="t('config.notification_set.token')">
|
||||
<ab-field :label="t('config.notification_set.token')">
|
||||
<input
|
||||
v-model="notificationData.token"
|
||||
type="text"
|
||||
class="setup-input setup-input-wide"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label :label="t('config.notification_set.chat_id')">
|
||||
<ab-field :label="t('config.notification_set.chat_id')">
|
||||
<input
|
||||
v-model="notificationData.chat_id"
|
||||
type="text"
|
||||
class="setup-input"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
|
||||
@@ -55,18 +55,18 @@ function handleNext() {
|
||||
<p class="step-subtitle">{{ t('setup.rss.subtitle') }}</p>
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('setup.rss.url')">
|
||||
<ab-field :label="t('setup.rss.url')">
|
||||
<input
|
||||
v-model="rssData.url"
|
||||
type="text"
|
||||
placeholder="https://mikanani.me/RSS/..."
|
||||
class="setup-input setup-input-wide"
|
||||
/>
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
|
||||
<ab-label v-if="rssData.name" :label="t('setup.rss.feed_name')">
|
||||
<ab-field v-if="rssData.name" :label="t('setup.rss.feed_name')">
|
||||
<input v-model="rssData.name" type="text" class="setup-input" />
|
||||
</ab-label>
|
||||
</ab-field>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
|
||||
@@ -488,7 +488,8 @@
|
||||
"copy": "Copy",
|
||||
"clear": "Clear",
|
||||
"toggle_password": "Show or hide password",
|
||||
"empty": "Nothing here yet"
|
||||
"empty": "Nothing here yet",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Light",
|
||||
|
||||
@@ -488,7 +488,8 @@
|
||||
"copy": "复制",
|
||||
"clear": "清空",
|
||||
"toggle_password": "显示或隐藏密码",
|
||||
"empty": "暂无内容"
|
||||
"empty": "暂无内容",
|
||||
"loading": "加载中…"
|
||||
},
|
||||
"theme": {
|
||||
"light": "浅色",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import type { Component } from 'vue';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import type { Config } from '#/config';
|
||||
import ConfigNormal from '@/components/setting/config-normal.vue';
|
||||
import ConfigParser from '@/components/setting/config-parser.vue';
|
||||
@@ -251,9 +251,8 @@ onBeforeRouteLeave(() => {
|
||||
class="config-rail"
|
||||
:aria-label="$t('config.search_placeholder')"
|
||||
>
|
||||
<input
|
||||
<ab-input
|
||||
v-model="searchQuery"
|
||||
ab-input
|
||||
class="rail-search"
|
||||
type="search"
|
||||
:placeholder="$t('config.search_placeholder')"
|
||||
|
||||
@@ -8,17 +8,6 @@ definePage({
|
||||
name: 'Downloader',
|
||||
});
|
||||
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
async function onDeleteSelected() {
|
||||
const ok = await confirm({
|
||||
title: t('downloader.action.delete'),
|
||||
body: t('downloader.action.delete_confirm'),
|
||||
confirmText: t('downloader.action.delete'),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) deleteSelected(false);
|
||||
}
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const { config } = storeToRefs(useConfigStore());
|
||||
@@ -33,6 +22,18 @@ const {
|
||||
toggleGroup,
|
||||
clearSelection,
|
||||
} = useDownloaderStore();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
async function onDeleteSelected() {
|
||||
const ok = await confirm({
|
||||
title: t('downloader.action.delete'),
|
||||
body: t('downloader.action.delete_confirm'),
|
||||
confirmText: t('downloader.action.delete'),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) deleteSelected(false);
|
||||
}
|
||||
|
||||
|
||||
const isNull = computed(() => {
|
||||
return config.value.downloader.host === '';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { watchOnce } from '@vueuse/core';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { countLogLevels, parseLogLines } from '@/utils/log-parse';
|
||||
import type { LogLevel } from '@/utils/log-parse';
|
||||
|
||||
@@ -171,9 +171,8 @@ onDeactivated(() => {
|
||||
</ab-button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
<ab-input
|
||||
v-model="searchQuery"
|
||||
ab-input
|
||||
class="log-search"
|
||||
type="search"
|
||||
:placeholder="$t('log.search_placeholder')"
|
||||
|
||||
@@ -11,15 +11,6 @@ definePage({
|
||||
const { t } = useMyI18n();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
async function onDeleteSelected() {
|
||||
const ok = await confirm({
|
||||
title: t('rss.delete'),
|
||||
body: t('rss.delete_confirm'),
|
||||
confirmText: t('rss.delete'),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) deleteSelected();
|
||||
}
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
const { rss, selectedRSS, isRefreshingAll, isLoading } = storeToRefs(
|
||||
useRSSStore()
|
||||
@@ -33,6 +24,16 @@ const {
|
||||
refreshAllRSS,
|
||||
} = useRSSStore();
|
||||
|
||||
async function onDeleteSelected() {
|
||||
const ok = await confirm({
|
||||
title: t('rss.delete'),
|
||||
body: t('rss.delete_confirm'),
|
||||
confirmText: t('rss.delete'),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) deleteSelected();
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
getAll();
|
||||
});
|
||||
@@ -167,19 +168,16 @@ const rssRowKey = (row: RSS) => row.id;
|
||||
</template>
|
||||
|
||||
<!-- Mobile: Card-based list -->
|
||||
<ab-data-list
|
||||
<ab-list
|
||||
v-if="isMobile"
|
||||
:items="rss || []"
|
||||
:columns="[
|
||||
{ key: 'name', title: t('rss.name') },
|
||||
{ key: 'url', title: t('rss.url') },
|
||||
]"
|
||||
:selectable="true"
|
||||
selectable
|
||||
key-field="id"
|
||||
:loading="isLoading && rss.length === 0"
|
||||
:selected="selectedRSS"
|
||||
@select="(keys) => (selectedRSS = keys as number[])"
|
||||
@update:selected="(keys) => (selectedRSS = keys as number[])"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<template #row="{ item }">
|
||||
<div class="rss-card-content">
|
||||
<div class="rss-card-name">{{ item.name }}</div>
|
||||
<div class="rss-card-url">{{ item.url }}</div>
|
||||
@@ -198,7 +196,11 @@ const rssRowKey = (row: RSS) => row.id;
|
||||
/>
|
||||
<ab-tag
|
||||
:type="item.enabled ? 'success' : 'neutral'"
|
||||
:title="item.enabled ? 'success' : 'neutral'"
|
||||
:title="
|
||||
item.enabled
|
||||
? $t('config.notification_set.enabled')
|
||||
: $t('config.notification_set.disabled')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- Inline on touch — a tooltip can't be hovered on a phone -->
|
||||
@@ -228,7 +230,7 @@ const rssRowKey = (row: RSS) => row.id;
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ab-data-list>
|
||||
</ab-list>
|
||||
|
||||
<!-- Desktop: Data table -->
|
||||
<NDataTable
|
||||
|
||||
9
webui/types/dts/components.d.ts
vendored
9
webui/types/dts/components.d.ts
vendored
@@ -9,27 +9,21 @@ export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
AbAdaptiveModal: typeof import('./../../src/components/basic/ab-adaptive-modal.vue')['default']
|
||||
AbAdd: typeof import('./../../src/components/basic/ab-add.vue')['default']
|
||||
AbAddRss: typeof import('./../../src/components/ab-add-rss.vue')['default']
|
||||
AbAlert: typeof import('./../../src/components/basic/ab-alert.vue')['default']
|
||||
AbBadge: typeof import('./../../src/components/basic/ab-badge.vue')['default']
|
||||
AbBangumiCard: typeof import('./../../src/components/ab-bangumi-card.vue')['default']
|
||||
AbBottomSheet: typeof import('./../../src/components/basic/ab-bottom-sheet.vue')['default']
|
||||
AbButton: typeof import('./../../src/components/basic/ab-button.vue')['default']
|
||||
AbButtonMulti: typeof import('./../../src/components/basic/ab-button-multi.vue')['default']
|
||||
AbChangeAccount: typeof import('./../../src/components/ab-change-account.vue')['default']
|
||||
AbConfirmHost: typeof import('./../../src/components/basic/ab-confirm-host.vue')['default']
|
||||
AbContainer: typeof import('./../../src/components/ab-container.vue')['default']
|
||||
AbDataList: typeof import('./../../src/components/basic/ab-data-list.vue')['default']
|
||||
AbEditRule: typeof import('./../../src/components/ab-edit-rule.vue')['default']
|
||||
AbEmpty: typeof import('./../../src/components/basic/ab-empty.vue')['default']
|
||||
AbField: typeof import('./../../src/components/basic/ab-field.vue')['default']
|
||||
AbFoldPanel: typeof import('./../../src/components/ab-fold-panel.vue')['default']
|
||||
AbIconButton: typeof import('./../../src/components/basic/ab-icon-button.vue')['default']
|
||||
AbImage: typeof import('./../../src/components/ab-image.vue')['default']
|
||||
AbInput: typeof import('./../../src/components/basic/ab-input.vue')['default']
|
||||
AbLabel: typeof import('./../../src/components/ab-label.vue')['default']
|
||||
AbList: typeof import('./../../src/components/basic/ab-list.vue')['default']
|
||||
AbMenu: typeof import('./../../src/components/basic/ab-menu.vue')['default']
|
||||
AbMobileNav: typeof import('./../../src/components/layout/ab-mobile-nav.vue')['default']
|
||||
@@ -37,10 +31,8 @@ declare module '@vue/runtime-core' {
|
||||
AbNotificationCenter: typeof import('./../../src/components/layout/ab-notification-center.vue')['default']
|
||||
AbOffsetMismatchDialog: typeof import('./../../src/components/basic/ab-offset-mismatch-dialog.vue')['default']
|
||||
AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']
|
||||
AbPopup: typeof import('./../../src/components/ab-popup.vue')['default']
|
||||
AbProgress: typeof import('./../../src/components/basic/ab-progress.vue')['default']
|
||||
AbPullRefresh: typeof import('./../../src/components/basic/ab-pull-refresh.vue')['default']
|
||||
AbRule: typeof import('./../../src/components/ab-rule.vue')['default']
|
||||
AbSearch: typeof import('./../../src/components/basic/ab-search.vue')['default']
|
||||
AbSearchBar: typeof import('./../../src/components/ab-search-bar.vue')['default']
|
||||
AbSearchCard: typeof import('./../../src/components/search/ab-search-card.vue')['default']
|
||||
@@ -55,7 +47,6 @@ declare module '@vue/runtime-core' {
|
||||
AbSplitButton: typeof import('./../../src/components/basic/ab-split-button.vue')['default']
|
||||
AbStatus: typeof import('./../../src/components/basic/ab-status.vue')['default']
|
||||
AbStatusBar: typeof import('./../../src/components/ab-status-bar.vue')['default']
|
||||
AbSwipeContainer: typeof import('./../../src/components/basic/ab-swipe-container.vue')['default']
|
||||
AbSwitch: typeof import('./../../src/components/basic/ab-switch.vue')['default']
|
||||
AbTag: typeof import('./../../src/components/basic/ab-tag.vue')['default']
|
||||
AbToolbar: typeof import('./../../src/components/basic/ab-toolbar.vue')['default']
|
||||
|
||||
@@ -118,17 +118,6 @@ export default defineConfig({
|
||||
|
||||
// input
|
||||
{
|
||||
// Soft Ink:填充式控件,无边框;边框只在聚焦(primary)/错误(danger)时出现
|
||||
'ab-input': `outline-none min-w-0 w-full sm:w-200 h-36 sm:h-28
|
||||
px-12 text-main text-left
|
||||
rounded-4
|
||||
border-1 border-transparent
|
||||
bg-surface-2 text-text-primary
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
transition-colors duration-150
|
||||
`,
|
||||
|
||||
'input-error': 'border-danger',
|
||||
'input-reset': 'bg-transparent min-w-0 flex-1 outline-none',
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user