Merge branch '3.2-dev-mobile-ui' into 3.2-dev

This commit is contained in:
Estrella Pan
2026-01-24 07:38:04 +01:00
29 changed files with 1336 additions and 138 deletions

View File

@@ -2,7 +2,7 @@
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#FAFAFA" />
<link rel="icon" href="/images/logo.svg" />

View File

@@ -78,7 +78,7 @@ const boxSize = computed(() => {
<ab-popup
v-model:show="show"
:title="popupTitle"
:css="`${boxSize} max-w-90vw`"
:css="`${boxSize} max-w-[90vw]`"
>
<div v-if="rule.deleted">
<div>{{ $t('homepage.rule.enable_hit') }}</div>

View File

@@ -24,14 +24,35 @@ const abLabel = computed(() => {
<style lang="scss" scoped>
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
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(.ab-select),
:deep(.n-dynamic-tags) {
width: 100%;
@include forTablet {
width: auto;
min-width: 200px;
}
}
}
.label-text {
font-size: 14px;
color: var(--color-text);
transition: color var(--transition-normal);
flex-shrink: 0;
}
</style>

View File

@@ -20,6 +20,7 @@ const props = withDefaults(
);
const show = defineModel('show', { default: false });
const { isMobile } = useBreakpointQuery();
function close() {
if (props.maskClick) {
@@ -29,7 +30,19 @@ function close() {
</script>
<template>
<TransitionRoot appear :show="show" as="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"

View File

@@ -35,7 +35,7 @@ const data = defineModel<any>('data');
v-bind="prop"
/>
<div v-else-if="type === 'dynamic-tags'" max-w-200 overflow-auto pb-1>
<div v-else-if="type === 'dynamic-tags'" w-full sm:max-w-200 overflow-auto pb-1>
<NDynamicTags v-model:value="data" size="small"></NDynamicTags>
</div>
</ab-label>

View File

@@ -84,12 +84,12 @@ function abLabel(label: string | (() => string)) {
.status-bar-actions {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
gap: 6px;
font-size: 18px;
@include forMobile {
gap: 8px;
font-size: 18px;
@include forTablet {
gap: 10px;
font-size: 20px;
}
}
@@ -100,9 +100,13 @@ function abLabel(label: string | (() => string)) {
transition: color var(--transition-fast), transform var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 4px;
// Ensure minimum touch target
min-width: var(--touch-target);
min-height: var(--touch-target);
padding: 8px;
border-radius: var(--radius-sm);
&:hover {
@@ -111,7 +115,7 @@ function abLabel(label: string | (() => string)) {
}
&:active {
transform: scale(1);
transform: scale(0.95);
}
&:focus-visible {
@@ -122,9 +126,9 @@ function abLabel(label: string | (() => string)) {
.status-menu {
position: absolute;
top: 40px;
top: 44px;
right: 0;
width: 160px;
width: 180px;
padding: 4px;
border-radius: var(--radius-md);
background: var(--color-surface);
@@ -136,10 +140,6 @@ function abLabel(label: string | (() => string)) {
transform-origin: top right;
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
@include forPC {
top: 44px;
}
}
@keyframes dropdown-in {
@@ -158,8 +158,8 @@ function abLabel(label: string | (() => string)) {
align-items: center;
gap: 8px;
width: 100%;
height: 32px;
padding: 0 10px;
min-height: var(--touch-target);
padding: 0 12px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text);
@@ -170,6 +170,10 @@ function abLabel(label: string | (() => string)) {
color: var(--color-primary);
background: var(--color-primary-light);
}
&:active {
transform: scale(0.98);
}
}
.status-menu-item-icon {

View File

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

View File

@@ -0,0 +1,193 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import { usePointerSwipe } from '@vueuse/core';
import {
TransitionRoot,
TransitionChild,
Dialog,
DialogPanel,
} from '@headlessui/vue';
const props = withDefaults(
defineProps<{
show: boolean;
title?: string;
closeable?: boolean;
maxHeight?: string;
}>(),
{
closeable: true,
maxHeight: '85vh',
}
);
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'close'): void;
}>();
const sheetRef = ref<HTMLElement | null>(null);
const dragHandleRef = ref<HTMLElement | null>(null);
const translateY = ref(0);
const isDragging = ref(false);
const sheetStyle = computed(() => {
if (isDragging.value && translateY.value > 0) {
return {
transform: `translateY(${translateY.value}px)`,
transition: 'none',
};
}
return {};
});
const { distanceY } = usePointerSwipe(dragHandleRef, {
threshold: 10,
onSwipe() {
if (distanceY.value < 0) {
// Swiping down (distanceY is negative when going down in usePointerSwipe)
translateY.value = Math.abs(distanceY.value);
isDragging.value = true;
}
},
onSwipeEnd() {
isDragging.value = false;
if (translateY.value > 100) {
close();
}
translateY.value = 0;
},
});
function close() {
if (props.closeable) {
emit('update:show', false);
emit('close');
}
}
</script>
<template>
<TransitionRoot :show="show" as="template">
<Dialog @close="close" class="ab-bottom-sheet">
<!-- Backdrop -->
<TransitionChild
enter="overlay-enter-active"
enter-from="overlay-enter-from"
leave="overlay-leave-active"
leave-to="overlay-leave-to"
>
<div class="ab-bottom-sheet__backdrop" aria-hidden="true" />
</TransitionChild>
<!-- Sheet panel -->
<TransitionChild
enter="sheet-enter-active"
enter-from="sheet-enter-from"
leave="sheet-leave-active"
leave-to="sheet-leave-to"
>
<div class="ab-bottom-sheet__container">
<DialogPanel
ref="sheetRef"
class="ab-bottom-sheet__panel"
:style="[sheetStyle, { maxHeight }]"
>
<!-- Drag handle -->
<div ref="dragHandleRef" class="ab-bottom-sheet__handle">
<div class="ab-bottom-sheet__handle-bar" />
</div>
<!-- Title -->
<div v-if="title" class="ab-bottom-sheet__header">
<h3 class="ab-bottom-sheet__title">{{ title }}</h3>
</div>
<!-- Content -->
<div class="ab-bottom-sheet__content">
<slot />
</div>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</TransitionRoot>
</template>
<style lang="scss" scoped>
.ab-bottom-sheet {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: flex-end;
&__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
}
&__container {
position: fixed;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: none;
}
&__panel {
position: relative;
width: 100%;
max-width: 640px;
background: var(--color-surface);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
box-shadow: var(--shadow-lg);
overflow: hidden;
display: flex;
flex-direction: column;
pointer-events: auto;
@include safeAreaBottom(padding-bottom);
}
&__handle {
display: flex;
justify-content: center;
padding: 12px 0 8px;
cursor: grab;
touch-action: none;
&:active {
cursor: grabbing;
}
}
&__handle-bar {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--color-border);
}
&__header {
padding: 0 20px 12px;
border-bottom: 1px solid var(--color-border);
}
&__title {
font-size: 18px;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
&__content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
-webkit-overflow-scrolling: touch;
}
}
</style>

View File

@@ -0,0 +1,223 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
export interface DataListColumn {
key: string;
title: string;
render?: (row: any) => string;
hidden?: boolean;
}
const props = withDefaults(
defineProps<{
items: any[];
columns: DataListColumn[];
selectable?: boolean;
keyField?: string;
}>(),
{
selectable: false,
keyField: 'id',
}
);
const emit = defineEmits<{
(e: 'select', keys: any[]): void;
(e: 'action', action: string, item: any): void;
(e: 'item-click', item: any): void;
}>();
const selectedKeys = ref<Set<any>>(new Set());
const visibleColumns = computed(() =>
props.columns.filter((col) => !col.hidden)
);
function toggleSelect(key: any) {
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: any, 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') || 'Select All' }}</span>
</label>
<span class="ab-data-list__count">{{ items.length }} items</span>
</div>
<!-- Items -->
<div
v-for="item in items"
:key="item[keyField]"
class="ab-data-list__item"
@click="emit('item-click', item)"
>
<!-- Checkbox -->
<div v-if="selectable" class="ab-data-list__checkbox" @click.stop>
<input
type="checkbox"
:checked="selectedKeys.has(item[keyField])"
@change="toggleSelect(item[keyField])"
/>
</div>
<!-- Card content -->
<div class="ab-data-list__card">
<slot name="item" :item="item" :columns="visibleColumns">
<!-- Default: key-value pairs -->
<div
v-for="col in visibleColumns"
:key="col.key"
class="ab-data-list__field"
>
<span class="ab-data-list__label">{{ col.title }}</span>
<span class="ab-data-list__value">{{ getCellValue(item, col) }}</span>
</div>
</slot>
</div>
</div>
<!-- Empty state -->
<div v-if="items.length === 0" class="ab-data-list__empty">
<slot name="empty">
<span>No data</span>
</slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.ab-data-list {
display: flex;
flex-direction: column;
gap: 8px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
&__select-all {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text-secondary);
cursor: pointer;
input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
}
&__count {
font-size: 12px;
color: var(--color-text-muted);
}
&__item {
display: flex;
align-items: stretch;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
&:active {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
}
&__checkbox {
display: flex;
align-items: center;
padding: 12px;
border-right: 1px solid var(--color-border);
input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
}
&__card {
flex: 1;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
&__field {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
&__label {
font-size: 12px;
color: var(--color-text-muted);
flex-shrink: 0;
}
&__value {
font-size: 14px;
color: var(--color-text);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
color: var(--color-text-muted);
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
const props = withDefaults(
defineProps<{
loading?: boolean;
threshold?: number;
disabled?: boolean;
}>(),
{
loading: false,
threshold: 60,
disabled: false,
}
);
const emit = defineEmits<{
(e: 'refresh'): void;
}>();
const containerRef = ref<HTMLElement | null>(null);
const pullDistance = ref(0);
const isPulling = ref(false);
const startY = ref(0);
const pullStyle = computed(() => {
if (pullDistance.value > 0) {
return {
transform: `translateY(${Math.min(pullDistance.value, props.threshold * 1.5)}px)`,
transition: isPulling.value ? 'none' : 'transform var(--transition-normal)',
};
}
return {
transition: 'transform var(--transition-normal)',
};
});
const indicatorOpacity = computed(() => {
return Math.min(pullDistance.value / props.threshold, 1);
});
const indicatorRotation = computed(() => {
return Math.min(pullDistance.value / props.threshold, 1) * 180;
});
const isTriggered = computed(() => pullDistance.value >= props.threshold);
function onTouchStart(e: TouchEvent) {
if (props.disabled || props.loading) return;
const container = containerRef.value;
if (!container || container.scrollTop > 0) return;
startY.value = e.touches[0].clientY;
isPulling.value = true;
}
function onTouchMove(e: TouchEvent) {
if (!isPulling.value || props.disabled || props.loading) return;
const container = containerRef.value;
if (!container || container.scrollTop > 0) {
isPulling.value = false;
pullDistance.value = 0;
return;
}
const currentY = e.touches[0].clientY;
const diff = currentY - startY.value;
if (diff > 0) {
// Apply resistance: the further you pull, the harder it gets
pullDistance.value = diff * 0.5;
e.preventDefault();
}
}
function onTouchEnd() {
if (!isPulling.value) return;
isPulling.value = false;
if (isTriggered.value && !props.loading) {
emit('refresh');
}
pullDistance.value = 0;
}
</script>
<template>
<div
ref="containerRef"
class="ab-pull-refresh"
@touchstart.passive="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<!-- Pull indicator -->
<div class="ab-pull-refresh__indicator" :style="{ opacity: indicatorOpacity }">
<div
v-if="loading"
class="ab-pull-refresh__spinner"
/>
<svg
v-else
class="ab-pull-refresh__arrow"
:style="{ transform: `rotate(${indicatorRotation}deg)` }"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</div>
<!-- Content -->
<div class="ab-pull-refresh__content" :style="pullStyle">
<slot />
</div>
</div>
</template>
<style lang="scss" scoped>
.ab-pull-refresh {
position: relative;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
height: 100%;
&__indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
height: 40px;
color: var(--color-primary);
pointer-events: none;
z-index: 1;
}
&__arrow {
transition: transform var(--transition-fast);
}
&__spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
&__content {
min-height: 100%;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,149 @@
<script lang="ts" setup>
import { ref, computed, watch, onMounted, nextTick } 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 }"
@click="goTo(i - 1)"
:aria-label="`Go to slide ${i}`"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.ab-swipe-container {
position: relative;
width: 100%;
&__track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
// Hide scrollbar
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
> * {
flex-shrink: 0;
width: 100%;
scroll-snap-align: start;
}
}
&__dots {
display: flex;
justify-content: center;
gap: 6px;
padding: 12px 0;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: var(--color-border);
cursor: pointer;
padding: 0;
transition: background var(--transition-fast), transform var(--transition-fast);
&--active {
background: var(--color-primary);
transform: scale(1.25);
}
}
}
</style>

View File

@@ -0,0 +1,134 @@
<script lang="ts" setup>
import {
Calendar,
Download,
Home,
Log,
Moon,
SettingTwo,
Sun,
} from '@icon-park/vue-next';
import InlineSvg from 'vue-inline-svg';
const { t } = useMyI18n();
const route = useRoute();
const { isDark, toggle: toggleDark } = useDarkMode();
const RSS = h(
'span',
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '20px', height: '20px' } },
h(InlineSvg, { src: './images/RSS.svg', width: '14', height: '14' })
);
const navItems = [
{ id: 1, icon: Home, label: () => t('sidebar.homepage'), path: '/bangumi' },
{ id: 2, icon: Calendar, label: () => t('sidebar.calendar'), path: '/calendar' },
{ id: 3, icon: RSS, label: () => t('sidebar.rss'), path: '/rss' },
{ id: 5, icon: Download, label: () => t('sidebar.downloader'), path: '/downloader',
hidden: localStorage.getItem('enable_downloader_iframe') !== '1' },
{ id: 6, icon: Log, label: () => t('sidebar.log'), path: '/log' },
{ id: 7, icon: SettingTwo, label: () => t('sidebar.config'), path: '/config' },
];
const visibleItems = computed(() => navItems.filter((i) => !i.hidden));
</script>
<template>
<nav class="mobile-nav" role="navigation" aria-label="Main navigation">
<RouterLink
v-for="item in visibleItems"
:key="item.id"
:to="item.path"
replace
class="mobile-nav__item"
:class="{ 'mobile-nav__item--active': route.path === item.path }"
:aria-label="item.label()"
>
<Component :is="item.icon" :size="18" class="mobile-nav__icon" />
<span class="mobile-nav__label">{{ item.label() }}</span>
</RouterLink>
<button
class="mobile-nav__item"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleDark"
>
<Moon v-if="!isDark" :size="18" class="mobile-nav__icon" />
<Sun v-else :size="18" class="mobile-nav__icon" />
<span class="mobile-nav__label">{{ isDark ? 'Light' : 'Dark' }}</span>
</button>
</nav>
</template>
<style lang="scss" scoped>
.mobile-nav {
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow-x: auto;
scrollbar-width: none;
@include safeAreaBottom(padding-bottom);
&::-webkit-scrollbar {
display: none;
}
&__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 0;
height: 56px;
padding: 6px 4px;
cursor: pointer;
user-select: none;
color: var(--color-text-muted);
background: transparent;
border: none;
border-radius: var(--radius-md);
transition: color var(--transition-fast),
background-color var(--transition-fast);
text-decoration: none;
font: inherit;
position: relative;
&:active {
transform: scale(0.95);
}
&--active {
color: var(--color-primary);
&::after {
content: '';
position: absolute;
top: 4px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 3px;
border-radius: var(--radius-full);
background: var(--color-primary);
}
}
}
&__icon {
flex-shrink: 0;
}
&__label {
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.2;
}
}
</style>

View File

@@ -25,7 +25,7 @@ const props = withDefaults(
const { t } = useMyI18n();
const { logout } = useAuth();
const route = useRoute();
const { isMobile } = useBreakpointQuery();
const { isMobile, isTablet, isMobileOrTablet } = useBreakpointQuery();
const { isDark, toggle: toggleDark } = useDarkMode();
const show = ref(props.open);
@@ -89,17 +89,15 @@ function Exit() {
title="logout"
class={[
'sidebar-item sidebar-item--action',
isMobile.value ? 'h-40' : '',
isMobileOrTablet.value ? 'h-40' : '',
]}
onClick={logout}
>
<Logout size={20} />
{!isMobile.value && show.value && <div class="sidebar-item-label">{t('sidebar.logout')}</div>}
{!isMobileOrTablet.value && show.value && <div class="sidebar-item-label">{t('sidebar.logout')}</div>}
</div>
);
}
const mobileItems = computed(() => items.filter((i) => i.id !== 4));
</script>
<template>
@@ -165,33 +163,46 @@ const mobileItems = computed(() => items.filter((i) => i.id !== 4));
</div>
</div>
<template #mobile>
<div class="mobile-nav">
<RouterLink
v-for="i in mobileItems"
:key="i.id"
:to="i.path"
replace
class="mobile-nav-item"
:class="[
route.path === i.path && 'mobile-nav-item--active',
i.hidden && 'hidden',
]"
>
<Component :is="i.icon" :size="20" />
</RouterLink>
<!-- Tablet: mini sidebar (icons only, no toggle) -->
<template #tablet>
<div class="sidebar sidebar--collapsed sidebar--tablet">
<div class="sidebar-inner">
<nav class="sidebar-nav">
<RouterLink
v-for="i in items"
:key="i.id"
:to="i.path"
replace
:title="i.label()"
class="sidebar-item"
:class="[
route.path === i.path && 'sidebar-item--active',
i.hidden && 'hidden',
]"
>
<Component :is="i.icon" :size="20" />
</RouterLink>
</nav>
<div
class="mobile-nav-item"
@click="toggleDark"
>
<Moon v-if="!isDark" :size="20" />
<Sun v-else :size="20" />
<div class="sidebar-footer">
<button
class="sidebar-item sidebar-item--action sidebar-item--theme"
:title="isDark ? 'Light mode' : 'Dark mode'"
@click="toggleDark"
>
<Moon v-if="!isDark" :size="20" />
<Sun v-else :size="20" />
</button>
<Exit />
</div>
</div>
<Exit />
</div>
</template>
<!-- Mobile: enhanced bottom navigation with labels -->
<template #mobile>
<ab-mobile-nav />
</template>
</media-query>
</template>
@@ -328,38 +339,21 @@ const mobileItems = computed(() => items.filter((i) => i.id !== 4));
transition: border-color var(--transition-normal);
}
// Mobile bottom nav
.mobile-nav {
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
// Tablet: fixed mini sidebar
.sidebar--tablet {
width: 56px;
.mobile-nav-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 44px;
cursor: pointer;
user-select: none;
color: var(--color-text-muted);
border-radius: var(--radius-md);
transition: color var(--transition-fast),
background-color var(--transition-fast);
&:hover {
color: var(--color-primary);
.sidebar-nav {
padding: 4px;
}
&--active {
color: var(--color-primary);
background: var(--color-primary-light);
.sidebar-item {
justify-content: center;
padding: 10px;
}
.sidebar-footer {
padding: 4px;
}
}
</style>

View File

@@ -136,21 +136,24 @@ onUnmounted(() => {
.topbar {
display: flex;
align-items: center;
height: 56px;
padding: 0 20px;
height: var(--topbar-height);
padding: 0 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition: background-color var(--transition-normal),
border-color var(--transition-normal),
box-shadow var(--transition-normal);
@include forMobile {
height: 48px;
padding: 0 12px;
border-radius: var(--radius-md);
@include forTablet {
padding: 0 16px;
}
@include forDesktop {
padding: 0 20px;
border-radius: var(--radius-lg);
}
}
@@ -167,28 +170,28 @@ onUnmounted(() => {
}
.topbar-logo {
width: 24px;
height: 24px;
width: 20px;
height: 20px;
@include forMobile {
width: 20px;
height: 20px;
@include forDesktop {
width: 24px;
height: 24px;
}
}
.topbar-wordmark {
height: 20px;
height: 16px;
position: relative;
@include forMobile {
height: 16px;
@include forDesktop {
height: 20px;
}
}
.topbar-search {
display: none;
@include forPC {
@include forTablet {
display: block;
}
}

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
const { isMobile } = useBreakpointQuery();
const { isMobile, isTablet } = useBreakpointQuery();
</script>
<template>
@@ -7,9 +7,16 @@ const { isMobile } = useBreakpointQuery();
<slot name="mobile"></slot>
</template>
<template v-else-if="isTablet">
<slot name="tablet">
<!-- Fallback: if no tablet slot provided, use mobile -->
<slot name="mobile"></slot>
</slot>
</template>
<template v-else>
<slot></slot>
</template>
</template>
<style lang="scss" scope></style>
<style lang="scss" scoped></style>

View File

@@ -2,15 +2,22 @@ import { createSharedComposable, useBreakpoints } from '@vueuse/core';
export const useBreakpointQuery = createSharedComposable(() => {
const breakpoints = useBreakpoints({
tablet: 640,
pc: 1024,
});
const isMobile = breakpoints.smaller('pc');
const isPC = breakpoints.isGreater('pc');
const isMobile = breakpoints.smaller('tablet'); // <640px (phones)
const isTablet = breakpoints.between('tablet', 'pc'); // 640-1023px (tablets)
const isPC = breakpoints.isGreater('pc'); // >=1024px (desktop)
const isMobileOrTablet = breakpoints.smaller('pc'); // <1024px (legacy isMobile)
const isTabletOrPC = breakpoints.greaterOrEqual('tablet'); // >=640px
return {
breakpoints,
isMobile,
isTablet,
isPC,
isMobileOrTablet,
isTabletOrPC,
};
});

View File

@@ -0,0 +1,15 @@
import { computed } from 'vue';
export function useSafeArea() {
const safeAreaTop = computed(() => 'env(safe-area-inset-top, 0px)');
const safeAreaBottom = computed(() => 'env(safe-area-inset-bottom, 0px)');
const safeAreaLeft = computed(() => 'env(safe-area-inset-left, 0px)');
const safeAreaRight = computed(() => 'env(safe-area-inset-right, 0px)');
return {
safeAreaTop,
safeAreaBottom,
safeAreaLeft,
safeAreaRight,
};
}

View File

@@ -43,9 +43,12 @@ const { updateRule, enableRule, ruleManage } = useBangumiStore();
<style lang="scss" scoped>
.layout-container {
width: 100%;
height: 100%;
height: 100dvh;
overflow: hidden;
padding: var(--layout-padding);
padding-left: calc(var(--layout-padding) + env(safe-area-inset-left, 0px));
padding-right: calc(var(--layout-padding) + env(safe-area-inset-right, 0px));
gap: var(--layout-gap);
display: flex;
@@ -54,37 +57,35 @@ const { updateRule, enableRule, ruleManage } = useBangumiStore();
background: var(--color-bg);
transition: background-color var(--transition-normal);
@include forPC {
@include forDesktop {
min-width: 1024px;
min-height: 768px;
}
@include forMobile {
overflow: hidden;
height: 100vh;
}
}
.layout-main {
display: flex;
flex-direction: column-reverse;
gap: var(--layout-gap);
overflow: hidden;
height: calc(100vh - 2 * var(--layout-padding) - 56px - var(--layout-gap));
flex: 1;
min-height: 0;
@include forMobile {
flex-direction: column-reverse;
height: calc(100vh - var(--layout-padding) * 2 - var(--layout-gap));
gap: var(--layout-gap);
@include forTablet {
flex-direction: row;
}
@include forDesktop {
flex-direction: row;
}
}
.layout-content {
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: var(--layout-gap);
}

View File

@@ -6,7 +6,16 @@ definePage({
const { bangumi } = storeToRefs(useBangumiStore());
const { getAll, openEditPopup } = useBangumiStore();
const { isMobile } = useBreakpointQuery();
const refreshing = ref(false);
async function onRefresh() {
refreshing.value = true;
try {
await getAll();
} finally {
refreshing.value = false;
}
}
onActivated(() => {
getAll();
@@ -14,6 +23,7 @@ onActivated(() => {
</script>
<template>
<ab-pull-refresh :loading="refreshing" @refresh="onRefresh">
<div class="page-bangumi">
<!-- Empty state guide -->
<div v-if="!bangumi || bangumi.length === 0" class="empty-guide">
@@ -55,7 +65,6 @@ onActivated(() => {
name="bangumi"
tag="div"
class="bangumi-grid"
:class="{ 'bangumi-grid--centered': isMobile }"
>
<ab-bangumi-card
v-for="i in bangumi"
@@ -68,6 +77,7 @@ onActivated(() => {
</transition-group>
</div>
</ab-pull-refresh>
</template>
<style lang="scss" scoped>
@@ -77,12 +87,18 @@ onActivated(() => {
}
.bangumi-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
&--centered {
justify-content: center;
@include forTablet {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
@include forDesktop {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 20px;
}
}

View File

@@ -4,7 +4,7 @@ definePage({
});
const { getConfig, setConfig } = useConfigStore();
const { isMobile } = useBreakpointQuery();
const { isMobile, isMobileOrTablet } = useBreakpointQuery();
onActivated(() => {
getConfig();
@@ -32,14 +32,14 @@ onActivated(() => {
<div class="config-actions">
<ab-button
:class="[{ 'flex-1': isMobile }]"
:class="[{ 'flex-1': isMobileOrTablet }]"
type="warn"
@click="getConfig"
>
{{ $t('config.cancel') }}
</ab-button>
<ab-button
:class="[{ 'flex-1': isMobile }]"
:class="[{ 'flex-1': isMobileOrTablet }]"
type="primary"
@click="setConfig"
>
@@ -61,7 +61,7 @@ onActivated(() => {
gap: 12px;
margin-bottom: auto;
@media (min-width: 1024px) {
@include forDesktop {
grid-template-columns: 1fr 1fr;
}
}
@@ -82,6 +82,7 @@ onActivated(() => {
margin-top: 16px;
padding: 12px 0;
backdrop-filter: blur(8px);
background: color-mix(in srgb, var(--color-background) 80%, transparent);
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
@include safeAreaBottom(padding-bottom, 12px);
}
</style>

View File

@@ -303,7 +303,7 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
.action-bar {
position: fixed;
bottom: 24px;
bottom: calc(24px + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%);
display: flex;
@@ -315,6 +315,17 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
border: 1px solid var(--color-border);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
z-index: 100;
max-width: calc(100vw - 32px);
@include forMobile {
bottom: calc(72px + env(safe-area-inset-bottom, 0px));
left: 16px;
right: 16px;
transform: none;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
}
.action-bar-count {
@@ -326,6 +337,14 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
.action-bar-buttons {
display: flex;
gap: 8px;
@include forMobile {
width: 100%;
:deep(.btn) {
flex: 1;
}
}
}
.fade-enter-active,

View File

@@ -187,7 +187,7 @@ onDeactivated(() => {
gap: 12px;
align-items: start;
@media (min-width: 1024px) {
@include forDesktop {
grid-template-columns: 3fr 2fr;
}
}
@@ -217,7 +217,7 @@ onDeactivated(() => {
align-items: flex-start;
gap: 12px;
@media (min-width: 1024px) {
@include forDesktop {
align-items: center;
gap: 20px;
}

View File

@@ -7,6 +7,7 @@ definePage({
});
const { t } = useMyI18n();
const { isMobile } = useBreakpointQuery();
const { rss, selectedRSS } = storeToRefs(useRSSStore());
const { getAll, deleteSelected, disableSelected, enableSelected } =
useRSSStore();
@@ -76,7 +77,37 @@ const RSSTableOptions = computed(() => {
<template>
<div class="page-rss">
<ab-container :title="$t('rss.title')">
<!-- Mobile: Card-based list -->
<ab-data-list
v-if="isMobile"
:items="rss || []"
:columns="[
{ key: 'name', title: t('rss.name') },
{ key: 'url', title: t('rss.url') },
]"
:selectable="true"
key-field="id"
@select="(keys) => (selectedRSS = keys as number[])"
>
<template #item="{ item }">
<div class="rss-card-content">
<div class="rss-card-name">{{ item.name }}</div>
<div class="rss-card-url">{{ item.url }}</div>
<div class="rss-card-tags">
<ab-tag v-if="item.parser" type="primary" :title="item.parser" />
<ab-tag v-if="item.aggregate" type="primary" title="aggregate" />
<ab-tag
:type="item.enabled ? 'active' : 'inactive'"
:title="item.enabled ? 'active' : 'inactive'"
/>
</div>
</div>
</template>
</ab-data-list>
<!-- Desktop: Data table -->
<NDataTable
v-else
v-bind="RSSTableOptions"
@update:checked-row-keys="(e) => (selectedRSS = (e as number[]))"
></NDataTable>
@@ -112,7 +143,43 @@ const RSSTableOptions = computed(() => {
.rss-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
gap: 8px;
@include forTablet {
gap: 10px;
}
}
// Mobile RSS card styles
.rss-card-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.rss-card-name {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rss-card-url {
font-size: 12px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rss-card-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
</style>

View File

@@ -1,17 +1,52 @@
$min-pc: 1024px;
// Breakpoints
$bp-tablet: 640px;
$bp-desktop: 1024px;
// Legacy alias
$min-pc: $bp-desktop;
// Mobile-first breakpoint mixins
@mixin forTablet {
@media screen and (min-width: $bp-tablet) {
@content;
}
}
@mixin forDesktop {
@media screen and (min-width: $bp-desktop) {
@content;
}
}
// Legacy aliases (backward compat)
@mixin forMobile {
@media screen and (max-width: ($min-pc - 1)) {
@media screen and (max-width: ($bp-desktop - 1)) {
@content;
}
}
@mixin forPC {
@media screen and (min-width: $min-pc) {
@media screen and (min-width: $bp-desktop) {
@content;
}
}
// Touch device detection
@mixin forTouch {
@media (hover: none) and (pointer: coarse) {
@content;
}
}
// Safe area support for notched devices
@mixin safeAreaBottom($property: padding-bottom, $extra: 0px) {
#{$property}: calc(#{$extra} + env(safe-area-inset-bottom, 0px));
}
@mixin safeAreaTop($property: padding-top, $extra: 0px) {
#{$property}: calc(#{$extra} + env(safe-area-inset-top, 0px));
}
@mixin bg-mouse-event($normal, $hover, $active) {
background: $normal;
transition: background-color var(--transition-normal);

View File

@@ -81,3 +81,69 @@
transform: translateY(-4px) scale(0.97);
}
}
// Bottom sheet slide-up
.sheet {
&-enter-active,
&-leave-active {
transition: transform var(--transition-slow), opacity var(--transition-normal);
}
&-enter-from {
transform: translateY(100%);
opacity: 0;
}
&-leave-to {
transform: translateY(100%);
opacity: 0;
}
}
// Backdrop overlay fade
.overlay {
&-enter-active,
&-leave-active {
transition: opacity var(--transition-normal);
}
&-enter-from,
&-leave-to {
opacity: 0;
}
}
// Horizontal swipe
.swipe-left {
&-enter-active,
&-leave-active {
transition: transform var(--transition-normal), opacity var(--transition-normal);
}
&-enter-from {
transform: translateX(100%);
opacity: 0;
}
&-leave-to {
transform: translateX(-100%);
opacity: 0;
}
}
.swipe-right {
&-enter-active,
&-leave-active {
transition: transform var(--transition-normal), opacity var(--transition-normal);
}
&-enter-from {
transform: translateX(-100%);
opacity: 0;
}
&-leave-to {
transform: translateX(100%);
opacity: 0;
}
}

View File

@@ -46,16 +46,25 @@
--scrollbar-thumb-color: rgba(108, 74, 182, 0.3);
--scrollbar-thumb-hover-color: rgba(108, 74, 182, 0.6);
// --- Layout ---
--layout-padding: 16px;
--layout-gap: 12px;
// --- Layout (mobile-first) ---
--layout-padding: 12px;
--layout-gap: 10px;
--topbar-height: 48px;
--nav-height: 56px;
--touch-target: 44px;
// --- Typography ---
--font-family: 'Inter', -apple-system, 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
@include forMobile {
--layout-padding: 12px;
--layout-gap: 10px;
@include forTablet {
--layout-padding: 14px;
--layout-gap: 12px;
}
@include forDesktop {
--layout-padding: 16px;
--layout-gap: 12px;
--topbar-height: 56px;
}
}

View File

@@ -111,6 +111,7 @@ declare global {
const useRSSStore: typeof import('../../src/store/rss')['useRSSStore']
const useRoute: typeof import('vue-router/auto')['useRoute']
const useRouter: typeof import('vue-router/auto')['useRouter']
const useSafeArea: typeof import('../../src/hooks/useSafeArea')['useSafeArea']
const useSearchStore: typeof import('../../src/store/search')['useSearchStore']
const useSlots: typeof import('vue')['useSlots']
const vi: typeof import('vitest')['vi']

View File

@@ -9,20 +9,25 @@ 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']
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']
AbCheckbox: typeof import('./../../src/components/basic/ab-checkbox.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']
AbFoldPanel: typeof import('./../../src/components/ab-fold-panel.vue')['default']
AbImage: typeof import('./../../src/components/ab-image.vue')['default']
AbLabel: typeof import('./../../src/components/ab-label.vue')['default']
AbMobileNav: typeof import('./../../src/components/layout/ab-mobile-nav.vue')['default']
AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']
AbPopup: typeof import('./../../src/components/ab-popup.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']
@@ -31,6 +36,7 @@ declare module '@vue/runtime-core' {
AbSidebar: typeof import('./../../src/components/layout/ab-sidebar.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']
AbTopbar: typeof import('./../../src/components/layout/ab-topbar.vue')['default']

View File

@@ -31,6 +31,7 @@ export default defineConfig({
],
theme: {
breakpoints: {
sm: '640px',
pc: '1024px',
},
colors: {
@@ -116,7 +117,7 @@ export default defineConfig({
// input
{
'ab-input': `outline-none min-w-0 w-200 h-28
'ab-input': `outline-none min-w-0 w-full sm:w-200 h-36 sm:h-28
px-12 text-main text-right
rounded-6
border-1 border-border