mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +08:00
fix(ui): 统一书架书库入口与空状态层级
This commit is contained in:
1 parent
3a253c1c1a
commit
854bfff1ce
6 files changed
+159
-61
No files matched your search
@@ -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`。
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(); }
|
||||
Reference in new issue
Block a user