fix(ui): 统一书架书库入口与空状态层级

This commit is contained in:
hectorqin committed 2026-09-21 14:25:05 +08:00
1 parent 3a253c1c1a
commit 854bfff1ce
6 files changed
+159 -61

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
# 书架与书库样式优化
日期:2026-09-21。
- 标题、辅助文字、设置入口统一;书架的“书库 / 书源”和书库的“文件管理 / 上传书籍”使用同样的图标文字入口。子目录的面包屑单列显示。
- 搜索框统一圆角与高度;空书架隐藏排序栏,刷新移至标题栏。
- 空状态统一为图标、标题、说明和主操作卡片。空书架可去搜书或打开书库;空书库可上传第一本书;存在未识别文件时引导查看文件管理;只读目录不显示上传操作。空库不再显示单独的“0 本”状态栏。
- 保留搜索、排序、目录导航和有书时的封面网格。
验证:Web 构建及 38 项书架、书库、权限相关测试通过。生产 bundle 在 Chromium 中检查 320/390/1280px 空状态、有书、子目录、无搜索结果、只读和深色主题;验证书源、书库、文件管理、上传文件选择器、排序、搜索清除操作,以及页面和导航无横向溢出。
浏览器脚本使用可控 API fixture;没有上传实际文件,也未进行 Android/iOS 真机测试。
```powershell
npm --prefix web run build
$env:CHROME_PATH='C:\Program Files\Google\Chrome\Application\chrome.exe'
node web/tools/ui-review/collections.mjs
```
截图输出到本目录 `collections-*.png`,重点预览:`collections-shelf-empty-390.png`、`collections-library-empty-390.png`、`collections-shelf-books.png`、`collections-library-books.png`。
+32
View File
@@ -3853,6 +3853,38 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin
.source-modal-content { overflow-y: auto; overscroll-behavior: contain; padding: 0 1rem 1rem; }
.source-modal-actions { display: flex; flex-wrap: wrap; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid var(--reader-line); flex: none; }
.source-modal-actions button { min-height: 44px; }
/* Collection pages share a quiet heading, labelled destinations and useful empty states. */
.shelf-head { align-items: center; padding-top: calc(1.25rem + var(--safe-top)); padding-bottom: .85rem; }
.shelf-head-actions { margin-top: 0; }
.shelf-head .icon-button, .collection-heading .icon-button { width: 44px; height: 44px; border-radius: .8rem; }
.shelf-subtitle { margin-top: .45rem; line-height: 1.5; }
.collection-header { padding: calc(1.25rem + var(--safe-top)) calc(.9rem + var(--safe-right)) .75rem calc(.9rem + var(--safe-left)); flex: none; }
.collection-heading { display: flex; align-items: center; gap: .5rem; }
.collection-heading-text { flex: 1; min-width: 0; }
.collection-heading h1 { margin: 0; font-size: 1.5rem; font-weight: 650; letter-spacing: -.02em; line-height: 1.15; }
.collection-heading p { margin: .45rem 0 0; font-size: .78rem; line-height: 1.5; }
.collection-links { display: flex; gap: .65rem; margin-top: .85rem; }
.shelf-links { max-width: calc(var(--shelf-measure) - 1.8rem); margin: 0 auto .9rem; padding-inline: calc(.9rem + var(--safe-left)) calc(.9rem + var(--safe-right)); }
.collection-link.button { display: flex; align-items: center; gap: .6rem; flex: 1; min-width: 0; min-height: 48px; padding: .7rem .85rem; font-size: .875rem; border-radius: .8rem; background: color-mix(in srgb, var(--reader-accent) 4%, var(--reader-paper)); border-color: color-mix(in srgb, var(--reader-line) 65%, transparent); text-align: start; }
.collection-link > span { flex: 1; }
.collection-link[hidden] { display: none; }
.collection-link > .icon:first-child { color: var(--reader-accent); }
.collection-link > .icon:last-child { color: var(--reader-muted); font-size: .8rem; }
.shelf-screen .shelf-search input, .library-search input { border-radius: .85rem; min-height: 48px; }
.library-browse-screen .library-search { margin-top: .35rem; }
.library-path { margin-top: .75rem; padding: .25rem 0 0; }
.library-path .manager-crumb { min-height: 44px; }
.collection-empty.empty-state { width: calc(100% - 2rem); max-width: 30rem; margin: clamp(1.5rem, 7vh, 4rem) auto 2rem; padding: 1.75rem 1.25rem; gap: .7rem; border: 1px solid var(--reader-line); border-radius: 1.25rem; background: color-mix(in srgb, var(--reader-surface) 45%, var(--reader-paper)); }
.collection-empty .empty-glyph { box-sizing: content-box; width: 30px; height: 30px; padding: 1rem; border-radius: 1.1rem; background: color-mix(in srgb, var(--reader-accent) 9%, transparent); color: var(--reader-accent); opacity: 1; margin-bottom: .5rem; }
.collection-empty p { font-size: 1.1rem; line-height: 1.5; }
.collection-empty p.muted { max-width: 23rem; font-size: .875rem; line-height: 1.8; text-wrap: pretty; }
.collection-empty .empty-actions { margin-top: .6rem; gap: .65rem; }
.collection-empty .button { min-height: 44px; font-size: .875rem; }
.library-browse-body > .manager-status { background: transparent; border: 0; margin: .75rem auto; }
@media (min-width: 72rem) {
.collection-header, .library-browse-body { width: 100%; max-width: 68rem; margin-inline: auto; }
}
@media (max-width: 600px) {
.source-entry-actions { flex-direction: column; gap: 0; }
.source-entry-top { align-items: center; }
+28 -29
View File
@@ -479,14 +479,18 @@ export class LibraryBrowseScreen {
const listing = state.listing;
return (
<>
<div className="panel-header library-header">
<IconButton label="返回" icon="arrow-left" onClick={() => this.options.onClose()} />
{/* The way to the other half. A *place*, not a filter, so it is a labelled
control rather than a bare glyph: the reader has to know that pressing
it leaves the books and opens a file manager, because that page can
delete things. */}
<IconTextButton label="文件管理" icon="folder" onClick={() => this.options.onOpenFiles()} />
<div className="manager-crumbs">
<header className="library-header collection-header">
<div className="collection-heading">
<IconButton label="返回" icon="arrow-left" onClick={() => this.options.onClose()} />
<div className="collection-heading-text"><h1>书库</h1><p className="muted">浏览本地书籍,发现下一本好书</p></div>
<IconButton label={'书架设置 · ' + this.options.settings.shelfDensity} icon="tune" onClick={() => this.patch({ settingsOpen: !state.settingsOpen })} />
</div>
<nav className="collection-links" aria-label="书库操作">
<button type="button" className="button collection-link" onClick={() => this.options.onOpenFiles()}><Icon name="folder" /><span>文件管理</span><Icon name="chevron-right" /></button>
<button type="button" aria-label="上传书籍" hidden={!listing?.writable} className="button collection-link" disabled={state.busy} onClick={() => this.uploadInput.click()}><Icon name="upload" /><span>上传书籍</span><Icon name="chevron-right" /></button>
</nav>
{(listing?.crumbs.length ?? 0) > 1 && <>
<div className="manager-crumbs library-path" aria-label="当前文件夹">
{(listing?.crumbs ?? []).map((crumb, index) => (
<>
{index > 0 ? <span className="manager-crumb-sep">/</span> : null}
@@ -502,19 +506,8 @@ export class LibraryBrowseScreen {
</>
))}
</div>
<IconButton
label="上传书籍"
icon="upload"
hidden={listing?.writable === false}
disabled={state.busy}
onClick={() => this.uploadInput.click()}
/>
<IconButton
label={`书架设置 · ${this.options.settings.shelfDensity}`}
icon="tune"
onClick={() => this.patch({ settingsOpen: !state.settingsOpen })}
/>
</div>
</>}
</header>
<div className="manager-body library-browse-body">
{/*
@@ -558,7 +551,7 @@ export class LibraryBrowseScreen {
onGo={(page) => this.goToPage(page)}
/>
) : null}
<div className="manager-status muted">{state.status || this.summary()}</div>
{(state.status || state.total > 0) && <div className="manager-status muted">{state.status || this.summary()}</div>}
</div>
{state.dialog ? (
@@ -577,24 +570,30 @@ export class LibraryBrowseScreen {
private grid(): ComponentChildren {
const state = this.state;
if (state.books.length === 0) {
if (state.bootstrapping) return null;
const hasFiles = (state.listing?.files ?? 0) > 0;
const writable = state.listing?.writable === true;
const hasQuery = state.query.length > 0;
return (
<div className="empty-state">
<div className="empty-state collection-empty">
<Icon name={hasQuery ? 'search' : 'book'} class="empty-glyph" />
<p>{hasQuery ? '没有匹配的书' : '这个文件夹里没有可阅读的书'}</p>
<p>{hasQuery ? '没有匹配的书' : hasFiles ? '这个文件夹里没有可阅读的书' : '书库还没有书'}</p>
<p className="muted">
{hasQuery
? `「${state.query}」在这个文件夹里没有匹配`
: `${state.listing?.files ?? 0} 个文件里没有被扫描成书的,文件页能看到每一行的原因`}
: hasFiles ? '这里有 ' + state.listing!.files + ' 个文件,可在文件管理中查看识别结果。'
: writable ? '上传 EPUB、TXT 等书籍,开始建立你的书库。' : '管理员添加书籍后,就能在这里浏览并加入书架。'}
</p>
<div className="empty-actions">
{hasQuery ? (
<button type="button" className="button" onClick={() => this.clearSearch()}>
<button type="button" className="button primary" onClick={() => this.clearSearch()}>
清除搜索
</button>
) : (
<IconTextButton label="文件管理" icon="folder" onClick={() => this.options.onOpenFiles()} />
)}
) : !hasFiles && writable ? (
<IconTextButton className="primary" label="上传第一本书" icon="upload" disabled={state.busy} onClick={() => this.uploadInput.click()} />
) : hasFiles ? (
<IconTextButton className="primary" label="文件管理" icon="folder" onClick={() => this.options.onOpenFiles()} />
) : null}
</div>
</div>
);
+14 -30
View File
@@ -836,28 +836,7 @@ export class ShelfScreen {
</p>
</div>
<div className="shelf-head-actions">
{/*
The library's entry point travels with the title rather than sitting
in the toolbar: it is a *place*, not a filter, and the toolbar is where
the filters are. It carries the folder and page the reader was last in
*there*, so switching back and forth is a toggle rather than a reset.
The two glyphs are `library` and `tune`. The row has now been through
two rounds of "这个 icon 还是很丑" (#40), and the second round is the
reason the *names* changed as well as the shapes: `gear` and
`magnifying-glass` were Font Awesome's names on our own outlines, so
anyone who knows that set opens `paths.mjs` expecting Font Awesome's
artwork and finds a different shape. The set is ours, so it is named
ours (`settings`, `search`, `menu` — see `docs/ui.md` §1.2), and the
header's two are drawn as few long strokes at 18px: `library` is three
spines on a shelf, and `tune` is one rail with two knobs.
*/}
<IconButton
label="书库"
icon="library"
onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}
/>
{this.options.onOpenSources && <button type="button" className="button" onClick={this.options.onOpenSources}>书源</button>}
{empty && !hasQuery && <IconButton label="刷新" icon="refresh" disabled={state.loading} onClick={() => void this.manualRefresh()} />}
<IconButton
label={`书架设置 · ${DENSITY_LABELS[density]}`}
icon="tune"
@@ -866,6 +845,10 @@ export class ShelfScreen {
</div>
</header>
<nav className="collection-links shelf-links" aria-label="发现书籍">
<button type="button" className="button collection-link" aria-label="书库" onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}><Icon name="library" /><span>书库</span><Icon name="chevron-right" /></button>
{this.options.onOpenSources && <button type="button" className="button collection-link" onClick={this.options.onOpenSources}><Icon name="search" /><span>书源</span><Icon name="chevron-right" /></button>}
</nav>
<div className="shelf-search" role="search">
<Icon name="search" class="search-glyph" />
<input
@@ -895,7 +878,7 @@ export class ShelfScreen {
</div>
<section className="shelf-section" aria-label={hasQuery ? '搜索结果' : '全部书籍'}>
<div className="shelf-toolbar">
{(!empty || hasQuery) && <div className="shelf-toolbar">
{hasQuery || state.total > 0 ? (
<span className="shelf-count muted" data-testid="shelf-count">
{hasQuery ? `找到 ${state.total} 本` : `共 ${state.total} 本`}
@@ -917,7 +900,7 @@ export class ShelfScreen {
</button>
))}
</div>
</div>
</div>}
<div className="shelf-refresh" hidden={!state.refreshing}>
{state.refreshing ? '正在刷新…' : ''}
@@ -927,7 +910,7 @@ export class ShelfScreen {
{!state.bootstrapping && empty ? (
hasQuery ? (
<div className="empty-state">
<div className="empty-state collection-empty">
<Icon name="search" class="empty-glyph" />
<p>没有匹配的书</p>
<p className="muted">换个关键词,或者检查一下作者名的写法</p>
@@ -936,19 +919,20 @@ export class ShelfScreen {
</button>
</div>
) : (
<div className="empty-state">
<div className="empty-state collection-empty">
<Icon name="book" class="empty-glyph" />
<p>书库还是空的</p>
<p>书架还没有书</p>
<p className="muted">
把书籍放进挂载的目录,扫一次,它们就会出现在这里
从书库挑选喜欢的书,或通过书源搜索,加入书架后就能开始阅读。
</p>
<div className="empty-actions">
{this.options.onOpenSources && <IconTextButton className="primary" icon="search" label="去搜书" onClick={this.options.onOpenSources} />}
<IconTextButton
className={this.options.onOpenSources ? '' : 'primary'}
icon="library"
label="打开书库"
onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}
/>
<IconTextButton icon="refresh" label="刷新" onClick={() => void this.manualRefresh()} />
</div>
</div>
)
@@ -1029,7 +1013,7 @@ export class ShelfScreen {
}
return base;
}
return '自部署书库';
return '收藏好书,随时接着读';
}
dispose(): void {
+3 -2
View File
@@ -256,7 +256,8 @@ describe('the browsing half of the library', () => {
);
void walker;
void walkerCalls;
expect(screen.element.querySelector('.manager-crumb')).not.toBeNull();
expect(screen.element.querySelector('.library-header h1')?.textContent).toBe('书库');
expect(screen.element.querySelector('.library-path')).toBeNull();
expect(calls).toEqual([]);
});
@@ -345,7 +346,7 @@ describe('the browsing half of the library', () => {
// The empty state sends the reader here, so the fix has to be here too. It is a
// write, and the file page's own upload is the same call — one endpoint, two
// pages that can open it.
expect(screen.element.querySelector('.panel-header [aria-label="上传书籍"]')).not.toBeNull();
expect(screen.element.querySelector('.library-header [aria-label="上传书籍"]')).not.toBeNull();
});
it('hides 上传 on a read-only mount', async () => {
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
import { chromium } from 'playwright';
import { createReviewServer } from './server.mjs';
const server = createReviewServer({ port: 0 });
await server.listen();
const base = 'http://127.0.0.1:' + server.server.address().port;
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) });
try {
await mkdir('docs/ui-review', { recursive: true });
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
const errors = []; page.on('pageerror', error => errors.push(error.message));
let empty = true, writable = true, files = 0;
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url());
if ((empty || url.searchParams.get('search') === '不存在的关键词') && url.pathname === '/api/v1/books') return route.fulfill({ json: { items: [], total: 0, page: 1, pageSize: 60 } });
if (empty && url.pathname === '/api/v1/library/browse') return route.fulfill({ json: {
path: '', crumbs: [{ name: '书库', path: '' }], parent: null, entries: [], total: files, files, dirs: 0, size: 0, writable,
} });
if (url.pathname === '/api/v1/sources/types') return route.fulfill({ json: { types: [] } });
if (url.pathname === '/api/v1/sources') return route.fulfill({ json: { sources: [] } });
if (url.pathname === '/api/v1/plugins') return route.fulfill({ json: { plugins: [] } });
if (url.pathname === '/api/v1/subscriptions') return route.fulfill({ json: { subscriptions: [] } });
return route.continue();
});
const button = name => page.getByRole('button', { name, exact: true });
async function shot(name) {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, name + ' page overflow');
assert.equal(await page.locator('.collection-links').evaluate(el => el.scrollWidth > el.clientWidth), false, name + ' navigation overflow');
await page.screenshot({ path: 'docs/ui-review/collections-' + name + '.png', animations: 'disabled' });
}
await page.goto(base);
await page.locator('input[autocomplete=username]').fill('review'); await page.locator('input[type=password]').fill('password12');
await page.locator('button[type=submit]').click(); await page.getByText('书架还没有书', { exact: true }).waitFor();
await button('刷新').click(); await page.getByText('书架还没有书', { exact: true }).waitFor();
assert.equal(await page.getByRole('group', { name: '排序方式' }).count(), 0);
for (const width of [320, 390, 1280]) { await page.setViewportSize({ width, height: 844 }); await shot('shelf-empty-' + width); }
await button('去搜书').click(); await page.getByRole('tab', { name: '搜书', exact: true }).waitFor();
await page.goto(base + '/#/shelf'); await button('打开书库').click();
await page.getByText('书库还没有书', { exact: true }).waitFor();
assert.equal(await page.getByText('0 本', { exact: true }).count(), 0);
for (const width of [320, 390, 1280]) { await page.setViewportSize({ width, height: 844 }); await shot('library-empty-' + width); }
const chooser = page.waitForEvent('filechooser'); await button('上传第一本书').click(); await chooser;
await page.evaluate(() => document.documentElement.dataset.theme = 'dark'); await shot('library-empty-dark');
await page.evaluate(() => document.documentElement.dataset.theme = 'light');
writable = false; await page.reload(); await page.getByText('管理员添加书籍后,就能在这里浏览并加入书架。').waitFor();
assert.equal(await button('上传书籍').isVisible(), false); assert.equal(await button('上传第一本书').count(), 0);
await page.setViewportSize({ width: 390, height: 844 }); await shot('library-readonly');
files = 3; await page.reload(); await page.getByText('这里有 3 个文件,可在文件管理中查看识别结果。').waitFor();
await page.locator('.empty-actions').getByRole('button', { name: '文件管理', exact: true }).click();
await page.locator('.library-files-screen').waitFor();
empty = false; await page.goto(base + '/#/shelf'); await page.locator('.book-grid .book-card').first().waitFor(); await shot('shelf-books');
await page.getByRole('button', { name: '按书名排序', exact: true }).click();
await button('书库').click(); await page.locator('.library-browse-grid .book-card').first().waitFor(); await shot('library-books');
await page.goto(base + '/#/library/folder'); await page.locator('.library-path').waitFor(); await shot('library-folder');
await page.getByRole('button', { name: '书库', exact: true }).click(); await page.waitForURL('**/#/library');
await page.getByRole('searchbox').fill('不存在的关键词'); await page.getByRole('searchbox').press('Enter');
await page.getByText('没有匹配的书', { exact: true }).waitFor(); await shot('library-no-results'); await button('清除搜索').last().click();
assert.deepEqual(errors, []);
console.log('PASS collection UI: empty/populated shelf and library, 320/390/1280px, dark mode, readonly, upload entry, source entry, folders, sort and search');
} finally { await browser.close(); await server.close(); }