fix(webui): 手机端界面溢出与重叠——顶栏、通知面板、底部导航、规则编辑、RSS、日历、下载器、插件市场与设置页

This commit is contained in:
Estrella Pan
2026-10-22 06:01:55 +02:00
parent 3d2a0eae6c
commit ce756d87cc
19 changed files with 219 additions and 90 deletions

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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)) {