fix(webui): improve mobile UI/UX layout and responsiveness

Mobile Layout Fixes:
- Fix config page horizontal overflow by adding min-width: 0 constraints
- Fix sticky action buttons positioning on config page
- Add loading states to config save/cancel buttons
- Reduce topbar padding and icon spacing for mobile
- Make search button fill space between logo and action icons
- Fix search modal header layout with close button visibility

Component Improvements:
- ab-topbar: Flex search button with "Click to search" text on mobile
- ab-status-bar: Reduce button sizes and gaps on mobile
- ab-fold-panel: Add max-width and overflow constraints
- ab-setting: Add max-width: 100% to prevent input overflow
- ab-search-modal: Reduce padding and element sizes on mobile
- ab-mobile-nav: Increase label font-size from 10px to 11px
- ab-sidebar: Fix toggle animation (rotateY → rotate)

Calendar & Bangumi Pages:
- Add lazy loading to poster images for better performance
- Move unknown air day items to separate section below grid
- Add actionable CTA button to empty state with useAddRss hook

Login Page:
- Add will-change: transform for background animation performance

i18n:
- Add click_to_search translation key for mobile search button
- Add add_rss_btn translation for empty state CTA

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-01-25 23:09:53 +01:00
parent 6b6ba75d80
commit f764279a51
16 changed files with 379 additions and 105 deletions

View File

@@ -36,6 +36,8 @@ withDefaults(
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
transition: border-color var(--transition-normal);
min-width: 0; // Allow panel to shrink below content size
max-width: 100%;
}
.fold-panel-header {
@@ -64,6 +66,7 @@ withDefaults(
padding: 12px 14px;
font-size: 14px;
color: var(--color-text);
overflow-x: hidden;
transition: background-color var(--transition-normal),
color var(--transition-normal);
}

View File

@@ -48,6 +48,14 @@ const data = defineModel<any>('data');
<style lang="scss" scoped>
.setting-item {
width: 100%;
// Prevent fixed-width inputs from causing horizontal overflow on mobile
:deep(input),
:deep(select),
:deep(.n-select),
:deep(.n-input) {
max-width: 100%;
}
}
.setting-divider {

View File

@@ -84,11 +84,11 @@ function abLabel(label: string | (() => string)) {
.status-bar-actions {
display: flex;
align-items: center;
gap: 6px;
gap: 2px;
font-size: 18px;
@include forTablet {
gap: 10px;
gap: 6px;
font-size: 20px;
}
}
@@ -104,11 +104,17 @@ function abLabel(label: string | (() => string)) {
background: transparent;
border: none;
// Ensure minimum touch target
min-width: var(--touch-target);
min-height: var(--touch-target);
padding: 8px;
min-width: 36px;
min-height: 36px;
padding: 6px;
border-radius: var(--radius-sm);
@include forTablet {
min-width: var(--touch-target);
min-height: var(--touch-target);
padding: 8px;
}
&:hover {
color: var(--color-primary);
transform: scale(1.1);

View File

@@ -123,7 +123,7 @@ const visibleItems = computed(() => navItems.filter((i) => !i.hidden));
}
&__label {
font-size: 10px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -269,7 +269,7 @@ function Exit() {
transition: transform var(--transition-normal);
&--open {
transform: rotateY(180deg);
transform: rotate(180deg);
}
}

View File

@@ -6,14 +6,17 @@ import {
PlayOne,
Power,
Refresh,
Search,
} from '@icon-park/vue-next';
import { ruleTemplate } from '#/bangumi';
const { t, changeLocale } = useMyI18n();
const { running, onUpdate, offUpdate } = useAppInfo();
const { showAddRss: showAddRSS, closeAddRss } = useAddRss();
const { toggleModal: openSearch } = useSearchStore();
const { isMobile } = useBreakpointQuery();
const showAccount = ref(false);
const showAddRSS = ref(false);
const rssRule = ref(ruleTemplate);
const { start, pause, shutdown, restart, resetRule } = useProgramStore();
@@ -72,6 +75,7 @@ const onSearchFocus = ref(false);
watch(showAddRSS, (val) => {
if (!val) {
rssRule.value = ruleTemplate;
closeAddRss();
}
});
@@ -86,26 +90,39 @@ onUnmounted(() => {
<template>
<div class="topbar">
<div class="topbar-left">
<div class="topbar-brand">
<img
:src="isDark ? '/images/logo-light.svg' : '/images/logo.svg'"
alt="favicon"
class="topbar-logo"
/>
<img
v-show="onSearchFocus === false"
:src="isDark ? '/images/AutoBangumi.svg' : '/images/AutoBangumi-dark.svg'"
alt="AutoBangumi"
class="topbar-wordmark"
/>
</div>
<div class="topbar-search">
<ab-search-bar />
</div>
<!-- Logo -->
<div class="topbar-brand">
<img
:src="isDark ? '/images/logo-light.svg' : '/images/logo.svg'"
alt="favicon"
class="topbar-logo"
/>
<img
v-if="!isMobile"
v-show="onSearchFocus === false"
:src="isDark ? '/images/AutoBangumi.svg' : '/images/AutoBangumi-dark.svg'"
alt="AutoBangumi"
class="topbar-wordmark"
/>
</div>
<!-- Desktop search bar -->
<div class="topbar-search">
<ab-search-bar />
</div>
<!-- Mobile search button (fills space) -->
<button
v-if="isMobile"
class="topbar-mobile-search"
:aria-label="$t('topbar.search.click_to_search')"
@click="openSearch"
>
<Search theme="outline" size="18" />
<span class="topbar-mobile-search-text">{{ $t('topbar.search.click_to_search') }}</span>
</button>
<!-- Right side actions -->
<div class="topbar-right">
<ab-status-bar
:items="items"
@@ -127,8 +144,9 @@ onUnmounted(() => {
.topbar {
display: flex;
align-items: center;
gap: 8px;
height: var(--topbar-height);
padding: 0 12px;
padding: 0 8px;
background: var(--color-surface);
border: 1px solid var(--color-border);
@@ -139,25 +157,26 @@ onUnmounted(() => {
box-shadow var(--transition-normal);
@include forTablet {
padding: 0 16px;
gap: 12px;
padding: 0 12px;
}
@include forDesktop {
gap: 16px;
padding: 0 20px;
border-radius: var(--radius-lg);
}
}
.topbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-shrink: 0;
@include forTablet {
gap: 10px;
}
}
.topbar-logo {
@@ -184,9 +203,50 @@ onUnmounted(() => {
@include forTablet {
display: block;
flex: 1;
max-width: 400px;
}
}
.topbar-mobile-search {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
height: 34px;
padding: 0 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface-hover);
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--transition-fast),
border-color var(--transition-fast),
background-color var(--transition-fast);
&:hover {
color: var(--color-primary);
border-color: var(--color-primary);
background: var(--color-primary-light);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.topbar-mobile-search-text {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-right {
flex-shrink: 0;
}
.topbar-right {
margin-left: auto;
}

View File

@@ -388,25 +388,37 @@ function clearFilters() {
.modal-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
transition: border-color var(--transition-normal);
@include forTablet {
gap: 12px;
padding: 16px;
}
}
.search-input-wrapper {
flex: 1;
min-width: 0; // Allow shrinking
display: flex;
align-items: center;
gap: 10px;
height: 44px;
padding-left: 14px;
gap: 8px;
height: 40px;
padding-left: 12px;
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast), background-color var(--transition-normal);
@include forTablet {
gap: 10px;
height: 44px;
padding-left: 14px;
}
&:focus-within {
border-color: var(--color-primary);
background: var(--color-surface);
@@ -451,19 +463,26 @@ function clearFilters() {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
gap: 4px;
height: 100%;
padding: 0 14px;
min-width: 90px;
padding: 0 10px;
min-width: 70px;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
cursor: pointer;
font-size: 13px;
font-size: 12px;
font-family: inherit;
transition: background-color var(--transition-fast);
@include forTablet {
gap: 6px;
padding: 0 14px;
min-width: 90px;
font-size: 13px;
}
&:hover {
background: var(--color-primary-hover);
}
@@ -515,8 +534,8 @@ function clearFilters() {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
width: 36px;
height: 36px;
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
@@ -525,6 +544,11 @@ function clearFilters() {
flex-shrink: 0;
transition: all var(--transition-fast);
@include forTablet {
width: 44px;
height: 44px;
}
&:hover {
background: var(--color-danger);
border-color: var(--color-danger);

View File

@@ -0,0 +1,23 @@
/**
* Composable to manage the Add RSS modal state globally.
* Allows triggering the Add RSS modal from anywhere in the app.
*/
// Global reactive state (shared across all component instances)
const showAddRss = ref(false);
export function useAddRss() {
const open = () => {
showAddRss.value = true;
};
const close = () => {
showAddRss.value = false;
};
return {
showAddRss,
openAddRss: open,
closeAddRss: close,
};
}

View File

@@ -132,7 +132,8 @@
"step2_title": "Configure Downloader",
"step2_desc": "Go to Config and set up your downloader (e.g. qBittorrent) connection.",
"step3_title": "Sit Back & Enjoy",
"step3_desc": "AutoBangumi will automatically download and rename new episodes for you."
"step3_desc": "AutoBangumi will automatically download and rename new episodes for you.",
"add_rss_btn": "Add RSS Feed"
},
"rule": {
"apply": "Apply",
@@ -430,7 +431,8 @@
"reset_rule": "Reset Rule",
"restart": "Restart",
"search": {
"placeholder": "Type to search"
"placeholder": "Type to search",
"click_to_search": "Click to search"
},
"shutdown": "Shutdown",
"start": "Start"

View File

@@ -132,7 +132,8 @@
"step2_title": "配置下载器",
"step2_desc": "前往设置页面,配置你的下载器(如 qBittorrent连接信息。",
"step3_title": "坐享其成",
"step3_desc": "AutoBangumi 将自动下载并重命名新剧集。"
"step3_desc": "AutoBangumi 将自动下载并重命名新剧集。",
"add_rss_btn": "添加 RSS 订阅"
},
"rule": {
"apply": "应用",
@@ -430,7 +431,8 @@
"reset_rule": "重置规则",
"restart": "重启",
"search": {
"placeholder": "输入关键字搜索"
"placeholder": "输入关键字搜索",
"click_to_search": "点击搜索"
},
"shutdown": "关闭",
"start": "启动"

View File

@@ -7,6 +7,7 @@ definePage({
const { bangumi, showArchived, isLoading, hasLoaded, activeBangumi, archivedBangumi } = storeToRefs(useBangumiStore());
const { getAll, openEditPopup } = useBangumiStore();
const { openAddRss } = useAddRss();
// Show skeleton when initially loading (not yet loaded and loading)
const showSkeleton = computed(() => !hasLoaded.value && isLoading.value);
@@ -130,6 +131,12 @@ function groupNeedsReview(group: BangumiGroup): boolean {
</div>
</div>
</div>
<div class="empty-guide-action anim-slide-up" style="--delay: 0.6s">
<ab-button type="primary" size="big" @click="openAddRss">
{{ $t('homepage.empty.add_rss_btn') }}
</ab-button>
</div>
</div>
<!-- Bangumi grid -->
@@ -597,6 +604,10 @@ function groupNeedsReview(group: BangumiGroup): boolean {
width: 100%;
}
.empty-guide-action {
margin-top: 24px;
}
.empty-guide-step {
display: flex;
align-items: flex-start;

View File

@@ -156,35 +156,95 @@ function onRuleSelect(rule: BangumiRule) {
</div>
<!-- Desktop: Grid columns -->
<div v-else-if="!isMobile" class="calendar-grid">
<div
v-for="(key, index) in [...DAY_KEYS, 'unknown']"
:key="key"
class="calendar-column anim-slide-up"
:class="{
'calendar-column--today': key !== 'unknown' && isToday(index),
'calendar-column--unknown': key === 'unknown'
}"
:style="{ '--delay': `${index * 0.05}s` }"
>
<!-- Day header -->
<div v-else-if="!isMobile" class="calendar-desktop">
<div class="calendar-grid">
<div
class="calendar-day-header"
:class="{ 'calendar-day-header--today': key !== 'unknown' && isToday(index) }"
v-for="(key, index) in DAY_KEYS"
:key="key"
class="calendar-column anim-slide-up"
:class="{
'calendar-column--today': isToday(index),
}"
:style="{ '--delay': `${index * 0.05}s` }"
>
<span class="calendar-day-label">{{ getDayLabel(key) }}</span>
<span
v-if="key !== 'unknown' && isToday(index)"
class="calendar-today-badge"
>
{{ $t('calendar.today') }}
</span>
</div>
<!-- Anime cards (grouped) -->
<div class="calendar-column-items">
<!-- Day header -->
<div
v-for="group in groupedBangumiByDay[key]"
class="calendar-day-header"
:class="{ 'calendar-day-header--today': isToday(index) }"
>
<span class="calendar-day-label">{{ getDayLabel(key) }}</span>
<span
v-if="isToday(index)"
class="calendar-today-badge"
>
{{ $t('calendar.today') }}
</span>
</div>
<!-- Anime cards (grouped) -->
<div class="calendar-column-items">
<div
v-for="group in groupedBangumiByDay[key]"
:key="group.key"
class="calendar-card-wrapper"
>
<div
class="calendar-card"
role="button"
tabindex="0"
:aria-label="`Edit ${group.primary.official_title}`"
@click="onCardClick(group)"
@keydown.enter="onCardClick(group)"
>
<div class="calendar-card-poster">
<img
v-if="group.primary.poster_link"
:src="posterSrc(group.primary.poster_link)"
:alt="group.primary.official_title"
class="calendar-card-img"
loading="lazy"
/>
<div v-else class="calendar-card-placeholder">
<ErrorPicture theme="outline" size="20" />
</div>
<div class="calendar-card-overlay">
<div class="calendar-card-overlay-tags">
<ab-tag :title="`S${group.primary.season}`" type="primary" />
<ab-tag
v-if="group.primary.group_name"
:title="group.primary.group_name"
type="primary"
/>
</div>
<div class="calendar-card-overlay-title">{{ group.primary.official_title }}</div>
</div>
</div>
</div>
<div v-if="group.rules.length > 1" class="group-badge">
{{ group.rules.length }}
</div>
</div>
<!-- Empty day -->
<div v-if="groupedBangumiByDay[key].length === 0" class="calendar-empty-day">
{{ $t('calendar.empty') }}
</div>
</div>
</div>
</div>
<!-- Unknown air day section (separate from main grid) -->
<div
v-if="groupedBangumiByDay.unknown.length > 0"
class="calendar-unknown-section anim-slide-up"
:style="{ '--delay': '0.4s' }"
>
<div class="calendar-unknown-header">
<span class="calendar-day-label">{{ getDayLabel('unknown') }}</span>
</div>
<div class="calendar-unknown-items">
<div
v-for="group in groupedBangumiByDay.unknown"
:key="group.key"
class="calendar-card-wrapper"
>
@@ -202,6 +262,7 @@ function onRuleSelect(rule: BangumiRule) {
:src="posterSrc(group.primary.poster_link)"
:alt="group.primary.official_title"
class="calendar-card-img"
loading="lazy"
/>
<div v-else class="calendar-card-placeholder">
<ErrorPicture theme="outline" size="20" />
@@ -223,11 +284,6 @@ function onRuleSelect(rule: BangumiRule) {
{{ group.rules.length }}
</div>
</div>
<!-- Empty day -->
<div v-if="groupedBangumiByDay[key].length === 0" class="calendar-empty-day">
{{ $t('calendar.empty') }}
</div>
</div>
</div>
</div>
@@ -272,6 +328,7 @@ function onRuleSelect(rule: BangumiRule) {
:src="posterSrc(group.primary.poster_link)"
:alt="group.primary.official_title"
class="calendar-row-img"
loading="lazy"
/>
<div v-else class="calendar-row-placeholder">
<ErrorPicture theme="outline" size="16" />
@@ -412,12 +469,19 @@ function onRuleSelect(rule: BangumiRule) {
to { transform: rotate(360deg); }
}
// Desktop layout
.calendar-desktop {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
// Desktop grid
.calendar-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-template-columns: repeat(7, 1fr);
gap: 10px;
flex: 1;
}
.calendar-column {
@@ -435,11 +499,28 @@ function onRuleSelect(rule: BangumiRule) {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary-light);
}
}
&--unknown {
grid-column: span 2;
background: var(--color-surface-hover);
}
// Unknown air day section
.calendar-unknown-section {
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 12px;
}
.calendar-unknown-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
margin-bottom: 10px;
}
.calendar-unknown-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.calendar-day-header {
@@ -485,12 +566,6 @@ function onRuleSelect(rule: BangumiRule) {
flex-direction: column;
gap: 8px;
flex: 1;
.calendar-column--unknown & {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
}
// Card wrapper for badge positioning

View File

@@ -4,7 +4,28 @@ definePage({
});
const { getConfig, setConfig } = useConfigStore();
const { isMobile, isMobileOrTablet } = useBreakpointQuery();
const { isMobileOrTablet } = useBreakpointQuery();
const isSaving = ref(false);
const isResetting = ref(false);
async function handleSave() {
isSaving.value = true;
try {
await setConfig();
} finally {
isSaving.value = false;
}
}
async function handleReset() {
isResetting.value = true;
try {
await getConfig();
} finally {
isResetting.value = false;
}
}
onActivated(() => {
getConfig();
@@ -33,16 +54,22 @@ onActivated(() => {
<div class="config-actions">
<ab-button
:size="isMobileOrTablet ? 'big' : 'normal'"
:class="[{ 'flex-1': isMobileOrTablet }]"
type="warn"
@click="getConfig"
type="secondary"
:loading="isResetting"
:disabled="isResetting || isSaving"
@click="handleReset"
>
{{ $t('config.cancel') }}
</ab-button>
<ab-button
:size="isMobileOrTablet ? 'big' : 'normal'"
:class="[{ 'flex-1': isMobileOrTablet }]"
type="primary"
@click="setConfig"
:loading="isSaving"
:disabled="isResetting || isSaving"
@click="handleSave"
>
{{ $t('config.apply') }}
</ab-button>
@@ -52,15 +79,20 @@ onActivated(() => {
<style lang="scss" scoped>
.page-config {
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.config-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-bottom: auto;
flex: 1;
min-width: 0; // Allow grid to shrink below content size
width: 100%;
@include forDesktop {
grid-template-columns: 1fr 1fr;
@@ -71,6 +103,8 @@ onActivated(() => {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0; // Allow column to shrink below content size
width: 100%;
}
.config-actions {
@@ -78,12 +112,35 @@ onActivated(() => {
bottom: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
justify-content: center;
gap: 12px;
margin-top: 16px;
padding: 12px 0;
backdrop-filter: blur(8px);
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
@include safeAreaBottom(padding-bottom, 12px);
padding: 12px;
border-radius: var(--radius-md);
backdrop-filter: blur(12px);
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
border: 1px solid var(--color-border);
// Override button max-width on mobile to allow flex grow
:deep(.btn) {
max-width: none;
}
@include forTablet {
justify-content: flex-end;
padding: 12px 0;
border-radius: 0;
border: none;
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
// Restore button max-width on tablet+
:deep(.btn) {
max-width: 170px;
&.btn--big {
max-width: 276px;
}
}
}
}
</style>

View File

@@ -153,6 +153,7 @@ async function handleLogin() {
filter: blur(100px);
opacity: 0.6;
animation: float 20s ease-in-out infinite;
will-change: transform;
}
&::before {

View File

@@ -86,6 +86,7 @@ declare global {
const toRefs: typeof import('vue')['toRefs']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAddRss: typeof import('../../src/hooks/useAddRss')['useAddRss']
const useApi: typeof import('../../src/hooks/useApi')['useApi']
const useAppInfo: typeof import('../../src/hooks/useAppInfo')['useAppInfo']
const useAttrs: typeof import('vue')['useAttrs']

View File

@@ -54,6 +54,7 @@ declare module '@vue/runtime-core' {
ConfigPasskey: typeof import('./../../src/components/setting/config-passkey.vue')['default']
ConfigPlayer: typeof import('./../../src/components/setting/config-player.vue')['default']
ConfigProxy: typeof import('./../../src/components/setting/config-proxy.vue')['default']
ConfigSearchProvider: typeof import('./../../src/components/setting/config-search-provider.vue')['default']
MediaQuery: typeof import('./../../src/components/media-query.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']