mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-04-23 18:11:37 +08:00
Merge branch '3.2-dev-mobile-ui' into 3.2-dev
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
45
webui/src/components/basic/ab-adaptive-modal.vue
Normal file
45
webui/src/components/basic/ab-adaptive-modal.vue
Normal 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>
|
||||
193
webui/src/components/basic/ab-bottom-sheet.vue
Normal file
193
webui/src/components/basic/ab-bottom-sheet.vue
Normal 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>
|
||||
223
webui/src/components/basic/ab-data-list.vue
Normal file
223
webui/src/components/basic/ab-data-list.vue
Normal 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>
|
||||
168
webui/src/components/basic/ab-pull-refresh.vue
Normal file
168
webui/src/components/basic/ab-pull-refresh.vue
Normal 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>
|
||||
149
webui/src/components/basic/ab-swipe-container.vue
Normal file
149
webui/src/components/basic/ab-swipe-container.vue
Normal 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>
|
||||
134
webui/src/components/layout/ab-mobile-nav.vue
Normal file
134
webui/src/components/layout/ab-mobile-nav.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
15
webui/src/hooks/useSafeArea.ts
Normal file
15
webui/src/hooks/useSafeArea.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
webui/types/dts/auto-imports.d.ts
vendored
1
webui/types/dts/auto-imports.d.ts
vendored
@@ -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']
|
||||
|
||||
6
webui/types/dts/components.d.ts
vendored
6
webui/types/dts/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user