fix(webui): 补修复验报告的低严重度问题,e2e 密码框精确匹配

- 插件市场:键值表标签列宽、分类 Tab 底线通栏、手机列表动画只在从详情返回时播放、
  插件不存在时手机页面标题
- 下载器:操作栏出现/消失时高度平滑过渡;640–767px 去掉下载速度列;
  桌面按可用宽度依次去掉做种/下载、ETA、上传速度,不再横向滚动
- 日志:手机级别筛选改为两列网格
- 空状态步骤序号与标题左对齐(role="list")
- 向导确认页、设置页标签列表、种子列表选中描边、手机搜索筛选区随结果滚动
- e2e:设置向导的密码框多了显示/隐藏按钮,getByLabel('Password') 改为精确匹配
This commit is contained in:
Estrella Pan
2026-10-22 19:48:51 +02:00
parent 0d97ae0098
commit 087dbedaa1
13 changed files with 233 additions and 109 deletions

View File

@@ -25,7 +25,7 @@ export async function loginThroughUI(
): Promise<Response> {
await page.goto('/#/login');
await page.getByLabel('Username').fill(credentials.username);
await page.getByLabel('Password').fill(credentials.password);
await page.getByLabel('Password', { exact: true }).fill(credentials.password);
const responsePromise = page.waitForResponse(
(response) =>
response.url().endsWith('/api/v1/auth/login') &&
@@ -60,7 +60,7 @@ export async function completeFirstRunSetup(
await page.getByLabel('Host').fill(environment.downloaderURL);
await page.getByLabel('Username').fill(environment.downloaderUsername);
await page.getByLabel('Password').fill(environment.downloaderPassword);
await page.getByLabel('Password', { exact: true }).fill(environment.downloaderPassword);
await page.getByLabel('Download Path').fill('/downloads/Bangumi');
await page.getByRole('button', { name: 'Next' }).click();

View File

@@ -37,7 +37,10 @@ const selectValue = computed<string | number | null>({
</script>
<template>
<div class="setting-item">
<div
class="setting-item"
:class="{ 'setting-item--tags': type === 'dynamic-tags' && data?.length }"
>
<AbField
:label="label"
:description="description"
@@ -104,6 +107,20 @@ const selectValue = computed<string | number | null>({
margin-top: 12px;
}
// 有标签时控件分两行(标签行 + 输入框):标签名对齐首行,不再居中于整块。
// 行高取 ab-tag 的高度(24px),使文字与标签行垂直居中
.setting-item--tags {
@include forTablet {
:deep(.ab-field-main) {
align-items: flex-start;
}
:deep(.ab-field-label) {
line-height: 24px;
}
}
}
// 与 ab-input 同宽(200px),各行控件左缘对齐;标签在框内换行
.dynamic-tags-wrapper {
width: 100%;

View File

@@ -235,6 +235,8 @@ onActivated(load);
&--selected:hover {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
outline: 1px solid var(--color-primary);
// 首行贴着滚动容器顶边,外描边会被裁掉,改为内描边
outline-offset: -1px;
}
}

View File

@@ -168,12 +168,12 @@ function permissionLabel(id: string) {
margin: 0;
font-size: 13px;
@include forMobile {
grid-template-columns: minmax(64px, 7.5em) 1fr;
}
dt {
color: var(--color-text-secondary);
@include forMobile {
max-width: 7.5em;
}
}
dd {

View File

@@ -1030,6 +1030,12 @@ function handleFilterClick(
max-height: calc(100vh - 100px);
}
// 触屏:筛选区随结果一起滚动,不再常驻占掉半个结果区;搜索栏吸顶
@include forTouch {
overflow-y: auto;
overscroll-behavior: contain;
}
@include forDesktop {
max-height: calc(100dvh - 120px);
@@ -1049,6 +1055,13 @@ function handleFilterClick(
flex-shrink: 0;
transition: border-color var(--transition-normal);
@include forTouch {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-surface);
}
@include forTablet {
gap: 12px;
padding: 16px;
@@ -1464,6 +1477,11 @@ function handleFilterClick(
flex: 1;
overflow-y: auto;
padding: 16px;
@include forTouch {
flex: none;
overflow-y: visible;
}
}
.empty-state {

View File

@@ -162,7 +162,8 @@ function maskPassword(pwd: string): string {
.review-item {
display: flex;
justify-content: space-between;
align-items: center;
// 长值换行时标签对齐值的首行
align-items: baseline;
gap: 12px;
padding: 4px 0;
font-size: 12px;

View File

@@ -179,7 +179,7 @@ function pinLeaving(el: Element) {
:description="$t('homepage.empty.subtitle')"
>
<template #action>
<ol class="empty-steps">
<ol class="empty-steps" role="list">
<li v-for="n in 3" :key="n">
<span class="empty-steps-title">{{
$t(`homepage.empty.step${n}_title`)

View File

@@ -7,6 +7,7 @@ import {
} from 'naive-ui';
import AbProgress from '@/components/basic/ab-progress.vue';
import AbTag from '@/components/basic/ab-tag.vue';
import { useElementSize, useMediaQuery } from '@vueuse/core';
import { useConfirm } from '@/hooks/useConfirm';
import type { QbTorrentInfo, TorrentGroup } from '#/downloader';
import { useDownloaderInstances } from '@/hooks/useDownloaderInstances';
@@ -183,6 +184,8 @@ function onCheckedChange(keys: DataTableRowKey[]) {
const downloaders = useDownloaderInstances();
const { isMobile, isTablet } = useBreakpointQuery();
// 平板窄档(640–767px)放不下下载速度列
const isNarrowTablet = useMediaQuery('(max-width: 767px)');
function progressCell(row: QbTorrentInfo) {
return h(AbProgress, {
@@ -215,6 +218,20 @@ function mobileNameCell(row: QbTorrentInfo) {
]);
}
// 各列定宽 + 名称列下限
function columnsWidth(cols: DataTableColumns<Row>) {
return cols.reduce((sum, col) => {
if (col.type === 'selection') return sum + 40;
const c = col as { width?: number; minWidth?: number };
return sum + (c.width ?? c.minWidth ?? 0);
}, 0);
}
// 桌面窄档(1024–1279px,或侧栏展开)放不下全部列:按可用宽度依次去掉
// 做种/下载、ETA、上传速度,不出现没有提示的横向滚动
const contentRef = ref<HTMLElement | null>(null);
const { width: contentWidth } = useElementSize(contentRef);
const tableColumnsValue = computed<DataTableColumns<Row>>(() => {
const nameCol: DataTableColumns<Row>[number] = {
title: t('downloader.torrent.name'),
@@ -222,7 +239,8 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => {
// CSS 省略号 + title:不用逐行测量宽度的 tooltip
ellipsis: !isMobile.value,
className: 'name-col',
minWidth: 200,
// 平板收紧名称与进度列,各列总宽不超过内容区(768px 时 672px)
minWidth: isTablet.value ? 150 : 200,
colSpan: (row: Row): number =>
isGroupRow(row) ? tableColumnsValue.value.length - 1 : 1,
cellProps: (row) => ({
@@ -239,13 +257,13 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => {
};
if (isMobile.value) return [{ type: 'selection' }, nameCol];
return [
let cols: DataTableColumns<Row> = [
{ type: 'selection' },
nameCol,
{
title: t('downloader.torrent.progress'),
key: 'progress',
width: 140,
width: isTablet.value ? 120 : 140,
render: torrentCell(progressCell),
},
{
@@ -261,13 +279,17 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => {
...numCol,
render: torrentCell((row) => formatSize(row.size)),
},
{
title: t('downloader.torrent.dlspeed'),
key: 'dlspeed',
width: 110,
...numCol,
render: torrentCell((row) => formatSpeed(row.dlspeed)),
},
...(isNarrowTablet.value
? []
: [
{
title: t('downloader.torrent.dlspeed'),
key: 'dlspeed',
width: 110,
...numCol,
render: torrentCell((row) => formatSpeed(row.dlspeed)),
},
]),
// 平板宽度不够:去掉上传速度、ETA、做种/下载
...(isTablet.value
? []
@@ -309,17 +331,16 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => {
]
: []),
];
for (const key of ['peers', 'eta', 'upspeed']) {
if (!contentWidth.value || columnsWidth(cols) <= contentWidth.value) break;
cols = cols.filter((col) => (col as { key?: string }).key !== key);
}
return cols;
});
// 表格最小宽度 = 各列定宽 + 名称列下限:WebKit 在 table-layout: fixed 下忽略
// <col> 的 min-width,不设 scroll-x 时名称列会被算成 0
const tableMinWidth = computed(() =>
tableColumnsValue.value.reduce((sum, col) => {
if (col.type === 'selection') return sum + 40;
const c = col as { width?: number; minWidth?: number };
return sum + (c.width ?? c.minWidth ?? 0);
}, 0)
);
// 表格最小宽度:WebKit 在 table-layout: fixed 下忽略 <col> 的 min-width,
// 不设 scroll-x 时名称列会被算成 0
const tableMinWidth = computed(() => columnsWidth(tableColumnsValue.value));
</script>
<template>
@@ -330,7 +351,7 @@ const tableMinWidth = computed(() =>
:description="$t('downloader.empty.subtitle')"
>
<template #action>
<ol class="empty-steps">
<ol class="empty-steps" role="list">
<li v-for="n in 3" :key="n">
<span class="empty-steps-title">{{
$t(`downloader.empty.step${n}_title`)
@@ -345,7 +366,7 @@ const tableMinWidth = computed(() =>
</ab-empty>
</div>
<div v-else class="downloader-content">
<div v-else ref="contentRef" class="downloader-content">
<div v-if="groups.length === 0 && !loading" class="downloader-empty">
{{ $t('downloader.empty_torrents') }}
</div>
@@ -370,28 +391,31 @@ const tableMinWidth = computed(() =>
@update:expanded-row-keys="onExpandedChange"
/>
<!-- 操作栏在内容列底部占位(不 fixed):不遮表格、不压底栏,无需预留 padding -->
<!-- 操作栏在内容列底部占位(不 fixed):不遮表格、不压底栏,无需预留 padding;
外层 grid 行高 0fr↔1fr 过渡,表格高度随之平滑变化 -->
<Transition name="action-bar">
<div v-if="selectedKeys.size > 0" class="action-bar">
<span class="action-bar-count">
{{ selectedKeys.size }} {{ $t('downloader.selected') }}
</span>
<div class="action-bar-buttons">
<ab-button variant="primary" size="sm" @click="resumeSelected">
{{
$t('downloader.action.resume')
}}
</ab-button>
<ab-button variant="secondary" size="sm" @click="pauseSelected">
{{
$t('downloader.action.pause')
}}
</ab-button>
<ab-button variant="danger" size="sm" @click="onDeleteSelected">
{{
$t('downloader.action.delete')
}}
</ab-button>
<div v-if="selectedKeys.size > 0" class="action-bar-wrap">
<div class="action-bar-clip">
<div class="action-bar">
<span class="action-bar-count">
{{ selectedKeys.size }} {{ $t('downloader.selected') }}
</span>
<div class="action-bar-buttons">
<ab-button variant="primary" size="sm" @click="resumeSelected">
{{ $t('downloader.action.resume') }}
</ab-button>
<ab-button variant="secondary" size="sm" @click="pauseSelected">
{{ $t('downloader.action.pause') }}
</ab-button>
<ab-button
variant="danger"
size="sm"
@click="onDeleteSelected"
>
{{ $t('downloader.action.delete') }}
</ab-button>
</div>
</div>
</div>
</div>
</Transition>
@@ -413,7 +437,6 @@ const tableMinWidth = computed(() =>
flex-direction: column;
flex: 1;
min-height: 0;
gap: 12px;
}
// flex-height:表格填满页面剩余高度,虚拟滚动在表体内进行
@@ -497,9 +520,22 @@ const tableMinWidth = computed(() =>
font-size: 14px;
}
.action-bar {
.action-bar-wrap {
flex-shrink: 0;
display: grid;
grid-template-rows: 1fr;
}
.action-bar-clip {
display: flex;
flex-direction: column;
min-height: 0;
}
.action-bar {
align-self: center;
// 与表格的间距用 margin 放进可收起的格子里,出现/收起时一并过渡
margin-top: 12px;
display: flex;
align-items: center;
gap: 16px;
@@ -539,13 +575,18 @@ const tableMinWidth = computed(() =>
.action-bar-enter-active,
.action-bar-leave-active {
transition: opacity var(--transition-normal),
transform var(--transition-normal);
transition: grid-template-rows var(--transition-normal),
opacity var(--transition-normal);
// 只在过渡中裁切,静止时不裁掉操作栏阴影
.action-bar-clip {
overflow: hidden;
}
}
.action-bar-enter-from,
.action-bar-leave-to {
grid-template-rows: 0fr;
opacity: 0;
transform: translateY(4px);
}
</style>

View File

@@ -319,12 +319,20 @@ onDeactivated(() => {
}
// 筛选项不换行:放不下时整组单独占一行并横向滚动,
// 搜索框与操作按钮落到下一行,不出现 3+1 的零散换行
// 搜索框与操作按钮落到下一行,不出现 3+1 的零散换行;
// 手机上 4 个筛选项排成 2×2,全部可见,不靠横向滚动
.filter-chips {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
@media (max-width: 639px) {
display: grid;
grid-template-columns: 1fr 1fr;
flex-basis: 100%;
overflow: visible;
}
}
.filter-chip {

View File

@@ -367,43 +367,47 @@ function permissionLabel(id: string) {
</template>
<div v-else-if="catalog" class="market__panes">
<nav
v-if="!showDetail"
class="market__list"
:aria-label="$t('market.list_label')"
>
<RouterLink
v-for="entry in visible"
:key="entry.id"
:to="`/market/${encodeURIComponent(entry.id)}`"
:replace="!isMobile"
class="row"
:class="{ 'row--on': !isMobile && selected?.id === entry.id }"
:aria-current="
!isMobile && selected?.id === entry.id ? 'true' : undefined
"
<!-- 手机上从详情返回时列表按 v-if 重新挂载:与进入详情同样的淡入上移;
不加 appear,从别的页面进来时只走路由过渡 -->
<Transition name="market-pane">
<nav
v-if="!showDetail"
class="market__list"
:aria-label="$t('market.list_label')"
>
<span class="row__name">{{ entry.name || entry.id }}</span>
<span class="row__version" :title="entry.version">{{ entry.version }}</span>
<span class="row__state">
<span
class="mark"
:class="`mark--${entryState(entry)}`"
aria-hidden="true"
/>
{{ stateLabel(entry) }}
</span>
<span class="row__author">{{ entry.authors.join(', ') }}</span>
</RouterLink>
<ab-empty
v-if="!visible.length"
:title="
query.trim()
? $t('market.no_match', { query: query.trim() })
: $t('market.no_filter_match')
"
/>
</nav>
<RouterLink
v-for="entry in visible"
:key="entry.id"
:to="`/market/${encodeURIComponent(entry.id)}`"
:replace="!isMobile"
class="row"
:class="{ 'row--on': !isMobile && selected?.id === entry.id }"
:aria-current="
!isMobile && selected?.id === entry.id ? 'true' : undefined
"
>
<span class="row__name">{{ entry.name || entry.id }}</span>
<span class="row__version" :title="entry.version">{{ entry.version }}</span>
<span class="row__state">
<span
class="mark"
:class="`mark--${entryState(entry)}`"
aria-hidden="true"
/>
{{ stateLabel(entry) }}
</span>
<span class="row__author">{{ entry.authors.join(', ') }}</span>
</RouterLink>
<ab-empty
v-if="!visible.length"
:title="
query.trim()
? $t('market.no_match', { query: query.trim() })
: $t('market.no_filter_match')
"
/>
</nav>
</Transition>
<section
v-if="selected && (!isMobile || showDetail)"
@@ -573,6 +577,12 @@ function permissionLabel(id: string) {
white-space: nowrap;
}
}
// 平板以上底线由 .market__top::after 画满整行;这里只留透明边框占位
@include forTablet {
border-bottom-color: transparent;
background: none;
}
}
// 手机:标题 | 目录状态 / 搜索 | 状态 / 分类;
@@ -593,6 +603,17 @@ function permissionLabel(id: string) {
'tabs meta meta';
grid-template-columns: minmax(0, 1fr) auto 140px;
gap: 8px 12px;
// 分类底线横跨整行(含目录状态),不随目录状态宽度伸缩;
// order 让它先画,选中下划线盖在上面
&:has(.market__tabs)::after {
content: '';
grid-column: 1 / -1;
grid-row: 2;
align-self: end;
order: -1;
border-bottom: 1px solid var(--color-border);
}
}
}
@@ -601,9 +622,9 @@ function permissionLabel(id: string) {
box-sizing: content-box;
height: 27px;
// 桌面画 ab-segmented 的底线;手机/平板已由 .market__tabs 的背景线画出
// 桌面与 ab-segmented 一样留 1px 底边;线由 .market__top::after 画
@include forDesktop {
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid transparent;
}
@include forTouch {
@@ -717,11 +738,11 @@ function permissionLabel(id: string) {
@include forTablet {
border-right: 1px solid var(--color-border);
}
}
// 手机上从详情返回时列表按 v-if 重新挂载:与进入详情同样的淡入上移
@include forMobile {
animation: detail-enter var(--transition-normal) both;
}
// 只在手机上从详情返回列表时触发(见模板里的 Transition)
.market-pane-enter-active {
animation: detail-enter var(--transition-normal) both;
}
// 右列最多占 45%,名称至少保留一半以上
@@ -868,12 +889,12 @@ function permissionLabel(id: string) {
margin: 0;
font-size: 13px;
@include forMobile {
grid-template-columns: minmax(64px, 7.5em) 1fr;
}
dt {
color: var(--color-text-secondary);
@include forMobile {
max-width: 7.5em;
}
}
dd {

View File

@@ -14,7 +14,7 @@ const { url } = storeToRefs(usePlayerStore());
:description="$t('player.empty.subtitle')"
>
<template #action>
<ol class="empty-steps">
<ol class="empty-steps" role="list">
<li v-for="n in 3" :key="n">
<span class="empty-steps-title">{{
$t(`player.empty.step${n}_title`)

View File

@@ -20,13 +20,18 @@ const ui = computed<PluginUiSlot | undefined>((prev) =>
(s) => s.slot === 'page' && s.plugin_id === route.params.id
)
);
// 同上:离场时保留上一个 id,标题不在淡出中变化
const pluginId = computed<string>((prev) =>
route.name !== 'Plugin' ? (prev ?? '') : route.params.id
);
</script>
<template>
<div class="page-plugin">
<!-- 手机上 ab-page-title 隐藏,页面自己给标题 -->
<h1 v-if="isMobile && ui" class="page-plugin__title">
{{ slotTitle(ui, lang) }}
<!-- 与 index.vue 的 pageTitle 一致:插件不存在时用 id 作标题 -->
<h1 v-if="isMobile && (ui || loaded || loadFailed)" class="page-plugin__title">
{{ ui ? slotTitle(ui, lang) : pluginId }}
</h1>
<PluginSlot v-if="ui" :key="`${ui.plugin_id}:${ui.element}`" :ui="ui" />
<ab-empty

View File

@@ -95,17 +95,28 @@ input::-webkit-inner-spin-button {
.empty-steps {
max-width: 52ch;
margin: 0 0 14px;
padding-left: 1.5em;
list-style: decimal;
padding-left: 0;
list-style: none;
counter-reset: empty-step;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-secondary);
li {
position: relative;
padding-left: 1.75em;
counter-increment: empty-step;
}
li + li {
margin-top: 6px;
}
li::marker {
// 自绘序号:左缘与上方标题对齐(::marker 落在内容区外,各引擎位置不一)
li::before {
content: counter(empty-step) '.';
position: absolute;
left: 0;
font-family: var(--font-mono);
color: var(--color-text-secondary);
}