mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 05:37:14 +08:00
fix(webui): 手机端界面溢出与重叠——顶栏、通知面板、底部导航、规则编辑、RSS、日历、下载器、插件市场与设置页
This commit is contained in:
@@ -62,11 +62,11 @@ export const apiSearch = {
|
||||
es.close();
|
||||
return;
|
||||
}
|
||||
console.error('EventSource error:', err);
|
||||
// onerror also fires when the server closes the stream after a
|
||||
// successful search — only count it as a failure when the connection
|
||||
// never opened (auth/server/network problem).
|
||||
if (status.value === 'CONNECTING') {
|
||||
console.error('EventSource error:', err);
|
||||
error.value = true;
|
||||
}
|
||||
close();
|
||||
|
||||
@@ -37,7 +37,7 @@ const { title = 'title', flush = false } = defineProps<{
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
@@ -456,6 +456,7 @@ function emitUnarchive() {
|
||||
// Review warning banner
|
||||
.review-warning {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
@@ -470,7 +471,7 @@ function emitUnarchive() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -502,6 +503,7 @@ function emitUnarchive() {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.review-warning-actions .detect-btn,
|
||||
|
||||
@@ -257,10 +257,11 @@ onActivated(load);
|
||||
}
|
||||
|
||||
.torrent-name {
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -47,16 +47,24 @@ const emit = defineEmits<{
|
||||
.advanced-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.n-dynamic-tags) {
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
min-width: 0;
|
||||
|
||||
.n-tag {
|
||||
height: 28px;
|
||||
max-width: calc(100% - 6px);
|
||||
margin: 2px 0 2px 6px !important;
|
||||
}
|
||||
|
||||
.n-tag__content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ function posterSrc(link: string | null | undefined): string {
|
||||
</div>
|
||||
<div class="calendar-row-info">
|
||||
<div class="calendar-row-title">
|
||||
{{ group.primary.official_title }}
|
||||
<span class="calendar-row-title-text">{{ group.primary.official_title }}</span>
|
||||
<span v-if="group.rules.length > 1" class="calendar-row-badge">
|
||||
{{ group.rules.length }}
|
||||
</span>
|
||||
@@ -216,11 +216,17 @@ function posterSrc(link: string | null | undefined): string {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-row-title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-row-meta {
|
||||
@@ -230,6 +236,7 @@ function posterSrc(link: string | null | undefined): string {
|
||||
}
|
||||
|
||||
.calendar-row-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -119,7 +119,7 @@ const visibleItems = computed(() => {
|
||||
&__item {
|
||||
// 不收缩:项多时导航栏横向滚动,保持 44px 以上的触控区域
|
||||
flex: 1 0 auto;
|
||||
min-width: 56px;
|
||||
min-width: var(--touch-target);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NPopover } from 'naive-ui';
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import { Bell, CircleAlert, CircleX, Info, Trash } from '@lucide/vue';
|
||||
import type { InboxMessage } from '@/api/notification';
|
||||
import { relativeTime } from '@/utils/relative-time';
|
||||
@@ -8,6 +9,9 @@ const { t } = useMyI18n();
|
||||
const router = useRouter();
|
||||
const store = useNotificationStore();
|
||||
const { messages, unreadCount, panelOpen } = storeToRefs(store);
|
||||
// 手机上面板放进底部抽屉:贴着铃铛的浮层在窄屏两侧都放不下
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
const [DefinePanelBody, PanelBody] = createReusableTemplate();
|
||||
|
||||
// 点条目跳转的目标页面;未列出的 kind 不跳转
|
||||
const KIND_ROUTES: Record<string, string> = {
|
||||
@@ -72,12 +76,86 @@ function onClearClick() {
|
||||
|
||||
<template>
|
||||
<div class="notification-center">
|
||||
<DefinePanelBody>
|
||||
<div class="notification-head">
|
||||
<span class="notification-head-title">
|
||||
{{ $t('notifications.title') }}
|
||||
<template v-if="unreadCount > 0">
|
||||
· {{ $t('notifications.unread', { n: unreadCount }) }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="notification-head-actions">
|
||||
<ab-button
|
||||
v-if="unreadCount > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="store.markAllRead()"
|
||||
>
|
||||
{{ $t('notifications.mark_all_read') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
v-if="messages.length > 0"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="onClearClick"
|
||||
>
|
||||
{{
|
||||
confirmClear
|
||||
? $t('notifications.clear_confirm')
|
||||
: $t('notifications.clear_all')
|
||||
}}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="notification-empty">
|
||||
{{ $t('notifications.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-list">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="notification-item"
|
||||
:class="{ 'notification-item--unread': !msg.read }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onItemClick(msg)"
|
||||
@keydown.enter="onItemClick(msg)"
|
||||
>
|
||||
<div class="notification-sev" :class="`is-${msg.severity}`">
|
||||
<Component :is="severityIcon(msg)" :size="14" />
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">
|
||||
{{ store.titleOf(msg) }}
|
||||
<span v-if="msg.count > 1" class="notification-count">×{{ msg.count }}</span>
|
||||
</div>
|
||||
<div class="notification-body">
|
||||
{{ store.bodyOf(msg) }}
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
{{ relativeTime(t, msg.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<ab-icon-button
|
||||
size="sm"
|
||||
class="notification-delete"
|
||||
:label="$t('notifications.delete')"
|
||||
@click.stop="store.remove(msg.id)"
|
||||
>
|
||||
<Trash :size="13" />
|
||||
</ab-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
</DefinePanelBody>
|
||||
|
||||
<NPopover
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
raw
|
||||
:show-arrow="false"
|
||||
:show="panelOpen"
|
||||
:show="panelOpen && !isMobile"
|
||||
@update:show="onShowChange"
|
||||
>
|
||||
<template #trigger>
|
||||
@@ -96,79 +174,19 @@ function onClearClick() {
|
||||
</template>
|
||||
|
||||
<div class="notification-panel" @keydown.esc="closeByEsc">
|
||||
<div class="notification-head">
|
||||
<span class="notification-head-title">
|
||||
{{ $t('notifications.title') }}
|
||||
<template v-if="unreadCount > 0">
|
||||
· {{ $t('notifications.unread', { n: unreadCount }) }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="notification-head-actions">
|
||||
<ab-button
|
||||
v-if="unreadCount > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="store.markAllRead()"
|
||||
>
|
||||
{{ $t('notifications.mark_all_read') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
v-if="messages.length > 0"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="onClearClick"
|
||||
>
|
||||
{{
|
||||
confirmClear
|
||||
? $t('notifications.clear_confirm')
|
||||
: $t('notifications.clear_all')
|
||||
}}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="notification-empty">
|
||||
{{ $t('notifications.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-list">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="notification-item"
|
||||
:class="{ 'notification-item--unread': !msg.read }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onItemClick(msg)"
|
||||
@keydown.enter="onItemClick(msg)"
|
||||
>
|
||||
<div class="notification-sev" :class="`is-${msg.severity}`">
|
||||
<Component :is="severityIcon(msg)" :size="14" />
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">
|
||||
{{ store.titleOf(msg) }}
|
||||
<span v-if="msg.count > 1" class="notification-count">×{{ msg.count }}</span>
|
||||
</div>
|
||||
<div class="notification-body">
|
||||
{{ store.bodyOf(msg) }}
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
{{ relativeTime(t, msg.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<ab-icon-button
|
||||
size="sm"
|
||||
class="notification-delete"
|
||||
:label="$t('notifications.delete')"
|
||||
@click.stop="store.remove(msg.id)"
|
||||
>
|
||||
<Trash :size="13" />
|
||||
</ab-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
<PanelBody />
|
||||
</div>
|
||||
</NPopover>
|
||||
|
||||
<ab-bottom-sheet
|
||||
v-if="isMobile"
|
||||
class="notification-sheet"
|
||||
:show="panelOpen"
|
||||
:show-close="false"
|
||||
@update:show="onShowChange"
|
||||
>
|
||||
<PanelBody />
|
||||
</ab-bottom-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -227,6 +245,10 @@ function onClearClick() {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification-sheet .ab-bottom-sheet__content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notification-panel {
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -224,6 +224,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -244,6 +245,10 @@ onUnmounted(() => {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar-mobile-search-text {
|
||||
|
||||
@@ -435,6 +435,13 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-main strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status-dot.disabled {
|
||||
|
||||
@@ -448,6 +448,10 @@ const tuningItems = computed(() => [
|
||||
font-size: 12px;
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-config {
|
||||
|
||||
@@ -597,11 +597,17 @@ function getFieldsForType(type: string) {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
> :first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled-badge {
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ChevronDown } from '@lucide/vue';
|
||||
import AbAlert from '../basic/ab-alert.vue';
|
||||
import AbButton from '../basic/ab-button.vue';
|
||||
import AbField from '../basic/ab-field.vue';
|
||||
@@ -276,7 +277,10 @@ onActivated(async () => {
|
||||
</p>
|
||||
|
||||
<details class="plugin__options">
|
||||
<summary>{{ $t('config.plugins_set.options') }}</summary>
|
||||
<summary>
|
||||
{{ $t('config.plugins_set.options') }}
|
||||
<ChevronDown :size="14" />
|
||||
</summary>
|
||||
<template v-if="plugin.config_schema">
|
||||
<PluginSchemaForm
|
||||
v-model="drafts[plugin.id]"
|
||||
@@ -376,10 +380,23 @@ onActivated(async () => {
|
||||
.plugin__options summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--touch-target);
|
||||
margin-bottom: var(--layout-gap);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.plugin__options[open] > summary {
|
||||
margin-bottom: var(--layout-gap);
|
||||
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.plugin__options summary:focus-visible {
|
||||
|
||||
@@ -636,7 +636,7 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
|
||||
.archived-badge {
|
||||
position: absolute;
|
||||
bottom: 36px; // Above the card title area
|
||||
top: 6px; // 海报左上角,不随标题高度移动
|
||||
left: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -345,7 +345,11 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="downloader-content">
|
||||
<div
|
||||
v-else
|
||||
class="downloader-content"
|
||||
:class="{ 'has-selection': selectedKeys.size > 0 }"
|
||||
>
|
||||
<div v-if="groups.length === 0 && !loading" class="downloader-empty">
|
||||
{{ $t('downloader.empty_torrents') }}
|
||||
</div>
|
||||
@@ -413,6 +417,13 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding-bottom: 60px;
|
||||
|
||||
// 手机端操作栏为两行布局,留出其高度,避免遮住最后几行
|
||||
&.has-selection {
|
||||
@media (max-width: 639px) {
|
||||
padding-bottom: 104px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flex-height:表格填满页面剩余高度,虚拟滚动在表体内进行
|
||||
@@ -459,7 +470,7 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
z-index: 100;
|
||||
max-width: calc(100vw - 32px);
|
||||
|
||||
@include forMobile {
|
||||
@media (max-width: 639px) {
|
||||
bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
@@ -480,7 +491,7 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@include forMobile {
|
||||
@media (max-width: 639px) {
|
||||
width: 100%;
|
||||
|
||||
:deep(.ab-btn) {
|
||||
@@ -498,6 +509,10 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
|
||||
@media (max-width: 639px) {
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-guide {
|
||||
|
||||
@@ -502,6 +502,23 @@ function permissionLabel(id: string) {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.market__tabs {
|
||||
@include forMobile {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.ab-segmented-tab) {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.market__top {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
@@ -629,6 +646,7 @@ function permissionLabel(id: string) {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: baseline;
|
||||
gap: 2px 10px;
|
||||
flex-shrink: 0;
|
||||
min-height: var(--touch-target);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
@@ -76,7 +76,7 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
|
||||
title: t('rss.url'),
|
||||
key: 'url',
|
||||
className: 'text-h3',
|
||||
minWidth: 400,
|
||||
minWidth: 240,
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
},
|
||||
@@ -261,8 +261,10 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
|
||||
:deep(.rss-name-cell) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
row-gap: 4px;
|
||||
min-width: 0;
|
||||
|
||||
.rss-name {
|
||||
@@ -292,6 +294,8 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
|
||||
// Mobile RSS card styles
|
||||
.rss-card-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
@@ -149,6 +149,19 @@ describe('downloader store', () => {
|
||||
|
||||
expect(errorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/downloads/Bangumi/Frieren/Season 1/', 'Frieren / Season 1'],
|
||||
['D:\\Downloads\\Bangumi\\Frieren\\Season 1\\', 'Frieren / Season 1'],
|
||||
])(
|
||||
'should label the group with the last folders when save_path is %s',
|
||||
(savePath, expected) => {
|
||||
const store = useDownloaderStore();
|
||||
store.torrents = [{ ...torrent('a', 'home'), save_path: savePath, size: 0 }];
|
||||
|
||||
expect(store.groups[0].name).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('hasConfiguredDownloader', () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
const seasonOnlyRegex = /^(Season\s*\d+|S\d+|第\d+季)$/i;
|
||||
|
||||
for (const [savePath, items] of map) {
|
||||
const parts = savePath.replace(/\/$/, '').split('/').filter(Boolean);
|
||||
const parts = savePath.split(/[\\/]/).filter(Boolean);
|
||||
let name = parts[parts.length - 1] || savePath;
|
||||
// If the last part is just a season folder, include the parent folder too
|
||||
if (parts.length >= 2 && seasonOnlyRegex.test(name)) {
|
||||
|
||||
Reference in New Issue
Block a user