diff --git a/docs/ui.md b/docs/ui.md index 2b9f3f5..27b05ae 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -97,7 +97,8 @@ ### 3.2 一列,封顶 -内容列封顶 68rem(`--shelf-measure`),宽屏加的是留白而不是更多列。 +首页、书架、影音、我的共用一条内容列:内容宽度封顶 `--page-measure`(`--collection-measure` 80rem 减去书架带内边距,约 1251px), +两侧留白 `--page-gutter`(手机 16px,侧栏模式 32px),切换标签页时内容边缘不动。宽屏加的是留白而不是更多列。 1600px 窗口排十一个封面、每个书名十二个字,比手机上的三列**更难扫**—— 这与「多出来的宽度是为了好读」正好相反。 diff --git a/web/src/app/router/routes.tsx b/web/src/app/router/routes.tsx index 8cc432c..ecde96e 100644 --- a/web/src/app/router/routes.tsx +++ b/web/src/app/router/routes.tsx @@ -63,7 +63,12 @@ export const routes = [{ { path: 'settings', element: }, { path: 'book/:bookId', element: }, // 我的 shares MediaLayout so it follows the chosen theme and keeps the channel rail. - { path: 'me', element: , children: [{ index: true, element: }] }, + // Its settings rows open /me/:panel (theme, browse, playback, plugins, account), + // so back from any of them returns to 我的. + { path: 'me', element: , children: [ + { index: true, element: }, + { path: ':panel', element: }, + ] }, { path: 'media', element: , children: [ { index: true, element: }, { path: 'music', element: }, diff --git a/web/src/app/shell.css b/web/src/app/shell.css index dac69cc..4a5f6b7 100644 --- a/web/src/app/shell.css +++ b/web/src/app/shell.css @@ -89,12 +89,42 @@ color: var(--reader-ink); } +/* ---- one page column for the four tabs ---- + * 首页, 书架, 影音 and 我的 share --page-measure / --page-gutter (styles/reader.css), so + * switching tabs never moves the content edges. The media-family pages are their own + * scrollers, so the column is made with padding (the scrollbar stays at the window + * edge); the shelf caps its bands instead, which keeps its sticky toolbar working. */ +@media (min-width: 701px) { + :root { --page-gutter: 32px; } +} +.media-screen:is(.home-page, .mine-page, .media-catalog-page) { + max-width: none; + padding-inline: max(var(--page-gutter), calc((100% - var(--page-measure)) / 2)); +} +/* Phone: the same 20px top as the shelf's header, clear of the notch; the shelf's + * 0.9rem band padding is topped up to the shared gutter. */ +@media (max-width: 700px) { + .media-screen:is(.home-page, .mine-page) { padding-top: calc(20px + var(--safe-top)); } + .shelf { padding-inline: calc(var(--page-gutter) - 0.9rem); } +} +@media (min-width: 701px) { + .shelf { --shelf-measure: var(--collection-measure); } + .shelf-head, .shelf-discovery, .shelf-section { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); } + .shelf-head { padding-top: 24px; } +} +/* Between the sidebar breakpoint and 64rem the discovery row is display: contents, + * so its two children are bands of their own. */ +@media (min-width: 701px) and (max-width: 63.99rem) { + .shelf-links, .shelf-search { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); } +} + /* The old fixed channel rail is gone; its top gutter goes with it. */ @media (min-width: 701px) { /* Only pages whose header scrolls with them; pages with a fixed header keep their 68px. */ body[data-app-nav] .media-screen:is(.media-catalog-page, .mine-page, .home-page) { padding-top: 24px; } - /* Search and the ⋯ menu used to hang from the rail with a negative offset. */ - body[data-app-nav] .media-heading:has(.media-mobile-title) .media-heading-actions { position: fixed; z-index: 81; top: 12px; right: 32px; } + /* Search and the ⋯ menu used to hang from the rail with a negative offset. They end + * where the page column ends, not at the window edge. */ + body[data-app-nav] .media-heading:has(.media-mobile-title) .media-heading-actions { position: fixed; z-index: 81; top: 12px; right: max(var(--page-gutter), calc((100vw - var(--app-nav-width) - var(--page-measure)) / 2)); } } /* Fixed page headers were placed to clear the old rail's brand; they now clear the sidebar. */ diff --git a/web/src/features/account/pages/MinePage.tsx b/web/src/features/account/pages/MinePage.tsx index 701d2d8..535f288 100644 --- a/web/src/features/account/pages/MinePage.tsx +++ b/web/src/features/account/pages/MinePage.tsx @@ -1,14 +1,19 @@ import { Link } from 'react-router-dom'; -import { ChevronRight, Film, LibraryBig, LogOut, Palette, PlayCircle, Rss, UserRound, type LucideIcon } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; +import { ChevronRight, Film, LayoutGrid, LibraryBig, LogOut, Palette, PlayCircle, Puzzle, RefreshCw, Rss, Settings, SlidersHorizontal, UserRound, type LucideIcon } from 'lucide-react'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; import { useAuthStore } from '../../../shared/stores/auth.store.ts'; +import { useSettingsStore } from '../../../shared/stores/settings.store.ts'; import { themeSummary } from '../../../shared/theme/theme.ts'; import { useThemeState } from '../../../shared/theme/use-theme.ts'; +import { readMediaPreferences } from '../../media/services/preferences.ts'; +import { DENSITY_LABELS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx'; +import { OpdsAccess } from '../../../ui/opds-access.tsx'; +import { SystemSettings } from '../../../ui/system-settings.tsx'; import '../../home/styles/home.css'; import '../styles/mine.css'; -function Row({ icon: Icon, label, value, to, onClick, danger = false }: { icon: LucideIcon; label: string; value?: string; to?: string; onClick?: () => void; danger?: boolean }) { +function Row({ icon: Icon, label, value, to, state, onClick, danger = false }: { icon: LucideIcon; label: string; value?: string; to?: string; state?: unknown; onClick?: () => void; danger?: boolean }) { const body = <> {label} @@ -17,7 +22,7 @@ function Row({ icon: Icon, label, value, to, onClick, danger = false }: { icon: ; const className = danger ? 'mine-row is-danger' : 'mine-row'; return to - ? {body} + ? {body} : ; } @@ -30,37 +35,58 @@ function serverHost(baseUrl: string): string { } /** - * 我的: the one place for who you are, what is connected, and the settings that - * already exist elsewhere. It owns no settings of its own: every row links to the - * page that does, so there is still exactly one place to change each thing. + * 我的: the settings hub. Every setting in the app is reachable from here, grouped by + * what it is about rather than by which page happens to own it: + * - 偏好: how things look and behave for *you* (theme, the shelf, media browsing, playback); + * - 管理: what an administrator changes for everyone (users, services, media libraries); + * - 账号: who you are on this server. + * Each row opens the one place that setting lives; the shelf sheet and 影音设置 are + * shortcuts to subsets of this list, not separate settings. */ export function MinePage() { const runtime = useRuntime(); const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null); + const settings = useSettingsStore(state => state.settings); const admin = user?.role === 'admin'; - const themeName = themeSummary(useThemeState(runtime.mediaApi.preferenceScope()).preference); + const scope = runtime.mediaApi.preferenceScope(); + const themeName = themeSummary(useThemeState(scope).preference); + const mediaDensity = readMediaPreferences(scope).density === 'compact' ? '紧凑' : '宽松'; + const [sheet, setSheet] = useState<'shelf' | 'opds' | 'system' | null>(null); const name = user?.displayName || user?.username || ''; - return
-

我的

- {user &&
- -
- {name} - -
-
} - - - - {admin && } - - - - - - - - { void runtime.api.signOut(); }} /> - -
; + return <> +
+

我的

+ {user &&
+ +
+ {name} + +
+
} + + + + {admin && } + + + + setSheet('shelf')} /> + + + + {admin && + setSheet('system')} /> + + + } + + + { void runtime.api.signOut(); }} /> + +
+ {/* Outside the media screen, so its sheet styles are the shelf's own, not media's. */} + { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} /> + {sheet === 'opds' && { void runtime.api.signOut(); }} onClose={() => setSheet('shelf')} />} + {sheet === 'system' && setSheet(null)} />} + ; } diff --git a/web/src/features/account/styles/mine.css b/web/src/features/account/styles/mine.css index ae963d7..506014a 100644 --- a/web/src/features/account/styles/mine.css +++ b/web/src/features/account/styles/mine.css @@ -1,5 +1,10 @@ /* 我的: reuses the media aliases of the theme tokens, so it follows the chosen theme. */ -.mine-page { max-width: 640px; } +/* The page column is the shared one (app/shell.css). On a wide window the groups sit + * two to a row instead of stretching each row across the whole column. */ +@media (min-width: 64rem) { + .mine-page { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; align-content: start; align-items: start; } + .mine-page > :not(.mine-group) { grid-column: 1 / -1; } +} .mine-page .media-back-button { color: inherit; text-decoration: none; } .mine-profile { diff --git a/web/src/features/home/styles/home.css b/web/src/features/home/styles/home.css index 3c138d9..4111d4c 100644 --- a/web/src/features/home/styles/home.css +++ b/web/src/features/home/styles/home.css @@ -1,6 +1,4 @@ /* 首页: continue where you left off. Uses the shared theme tokens. */ -.home-page { max-width: 1000px; } -.media-screen.home-page { padding-top: 20px; } .home-header h1 { margin: 6px 0 0; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 28px; line-height: 1.2; } .home-header p { margin: 4px 0 0; font-size: 14px; color: var(--muted); } diff --git a/web/src/features/media/components/settings.tsx b/web/src/features/media/components/settings.tsx index 8d92579..8e3dc38 100644 --- a/web/src/features/media/components/settings.tsx +++ b/web/src/features/media/components/settings.tsx @@ -2,19 +2,21 @@ import {FloatingConfirm} from '../../../ui/floating-confirm.tsx'; import {MediaSelect} from './select.tsx'; import {PlaybackSettings} from './playback-settings.tsx'; import {MediaThemeSettings} from './theme-settings.tsx'; -import {themeSummary} from '../../../shared/theme/theme.ts'; -import {useThemeState} from '../../../shared/theme/use-theme.ts'; import type {AppSettings} from '../../../store/settings.ts'; export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'plugins'|'account'; import { useState } from 'react'; -import {ChevronLeft,ChevronRight,Clock3,Heart,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Palette} from 'lucide-react'; +import {ChevronLeft,ChevronRight,Clock3,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Puzzle} from 'lucide-react'; import type {MediaPlayer} from '../services/player.ts'; import type {MediaApi,MediaAccount} from '../api/media-api.ts'; import {MediaProviderSettings} from './provider-settings.tsx'; import {defaultMediaPreferences,saveMediaPreferences,type MediaPreferences} from '../services/preferences.ts'; -export function MediaSettings({scope,preferences,player,admin,api: _api,account,onSaved,onBack,onPersonal,onManage,onTasks,onPlayback,panel:routePanel,onPanelChange,onReaderSettings}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onPersonal:(view:'favorites'|'history'|'queue')=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string;onReaderSettings?:(patch:Partial)=>void}){ - const {preference:themePreference}=useThemeState(scope); +/** + * 影音设置: only what is about media. Theme, account and the rest of the app-wide + * settings live in 我的; their panels are still rendered here (我的 links to them), + * but the media home panel does not list them a second time. + */ +export function MediaSettings({scope,preferences,player,admin,api: _api,account,onSaved,onBack,onManage,onTasks,onPlayback,onGeneral,panel:routePanel,onPanelChange,onReaderSettings,backLabel='返回影音设置'}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;onGeneral?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string;onReaderSettings?:(patch:Partial)=>void;backLabel?:string}){ const [localPanel,setLocalPanel]=useState('home'); const panel=routePanel??localPanel; function setPanel(value:SettingsPanel){if(onPanelChange)onPanelChange(value);else setLocalPanel(value);} @@ -25,23 +27,18 @@ export function MediaSettings({scope,preferences,player,admin,api: _api,account, function save(){setError('');setSaved(false);try{const value=saveMediaPreferences(scope,draft);onSaved(value);setSaved(true);}catch(error){setError(error instanceof Error?error.message:'保存失败');}} function change(patch:Partial){setDraft({...draft,...patch});setSaved(false);} function openBrowse(){if(!dirty)setDraft({...preferences});setError('');setSaved(false);setPanel('browse');} - const title=panel==='theme'?'主题外观':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='plugins'?'来源与刮削':panel==='account'?'账号':'影音设置'; + const title=panel==='theme'?'外观与主题':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='plugins'?'来源与刮削':panel==='account'?'账号与连接':'影音设置'; const link=(Icon:typeof Clock3,label:string,description:string,onClick:()=>void)=>; return
-

{title}

+

{title}

{leaving&&setLeaving(false)} onConfirm={()=>{setLeaving(false);setDraft({...preferences});setPanel('home');}}/>} {panel==='home'?<> - {account&&
{account.displayName||account.username}{account.role==='admin'?'管理员':'普通用户'} · 当前服务器
} -

我的内容

- {link(Heart,'我的收藏','',()=>onPersonal('favorites'))} - {link(Clock3,'播放历史','',()=>onPersonal('history'))} -

偏好

- {link(Palette,'主题外观',themeSummary(themePreference),()=>setPanel('theme'))} +

偏好

{link(SlidersHorizontal,'浏览偏好',preferences.density==='compact'?'紧凑':'宽松',openBrowse)} {link(Headphones,'播放设置','倍速与连续播放',()=>setPanel('playback'))}
- {admin&&

管理员

{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}
} - {account&&

账号

{link(UserRound,'账号与连接','',()=>setPanel('account'))}
} + {admin&&

管理员

{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}{_api&&link(Puzzle,'来源与刮削','TMDB、MusicBrainz 等资料来源',()=>setPanel('plugins'))}
} + {onGeneral&&

通用

{link(UserRound,'外观、账号与系统设置','在「我的」中管理',onGeneral)}
} :panel==='theme'?:panel==='browse'?
{event.preventDefault();save();}}>

仅影响影音页面,在当前设备为此服务器和账号保存。

diff --git a/web/src/features/media/pages/MediaLibrariesPage.tsx b/web/src/features/media/pages/MediaLibrariesPage.tsx index 9a14f5b..7b6cee1 100644 --- a/web/src/features/media/pages/MediaLibrariesPage.tsx +++ b/web/src/features/media/pages/MediaLibrariesPage.tsx @@ -1,6 +1,6 @@ import { Alert } from '@mantine/core'; import { MoreHorizontal, Plus, RefreshCw } from 'lucide-react'; -import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; @@ -20,6 +20,8 @@ function safeChannel(value: string | undefined): MediaChannel { export function MediaLibrariesPage() { const runtime = useRuntime(); const navigate = useNavigate(); + // Opened from 我的, back returns there; otherwise to the channel's 影音设置. + const fromMine = useLocation().state?.returnTo === '/me'; const { channel: routeChannel } = useParams(); const channel = safeChannel(routeChannel); const role = useAuthStore(state => state.verifiedUser?.role); @@ -49,7 +51,7 @@ export function MediaLibrariesPage() { }; const scan = (id: string) => act(id, () => runtime.mediaApi.request(`libraries/${encodeURIComponent(id)}/scan`, 'POST'), '扫描请求失败,请稍后重试。'); const cancel = (job: ScanJob) => act(job.libraryId ?? job.id, () => runtime.mediaApi.request(`jobs/${encodeURIComponent(job.id)}/cancel`, 'POST'), '取消扫描失败,请稍后重试。'); - return + return
{ void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} /> {opdsOpen && { void runtime.api.signOut(); }} onClose={() => { setOpdsOpen(false); setSettingsOpen(true); }} />} - {systemSettingsOpen && setSystemSettingsOpen(false)} />} {menuTarget && undefined } satisfies Dialog} destructive="从书架拿掉" onClose={answer => { const target = menuTarget; setMenuTarget(null); if (typeof answer === 'string' && answer) void act(target, answer as BookAction); }} />} {groupsOpen && undefined } satisfies Dialog} destructive="" onClose={answer => { setGroupsOpen(false); diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index a3dc750..9994596 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -32,6 +32,15 @@ /* Colours are the theme's: styles/theme-tokens.css. */ --reader-radius: 10px; --collection-measure: 80rem; + /* + * The page column every top-level page shares (首页, 书架, 影音, 我的, and the + * collection pages behind them): the content between the gutters is at most + * `--page-measure` wide and never nearer the edge than `--page-gutter`. Bands + * that carry their own 0.9rem inner padding (the shelf's) add it back, so the + * *content* edges line up across pages rather than the boxes. + */ + --page-measure: calc(var(--collection-measure) - 1.8rem); + --page-gutter: 16px; /* * How far a search field is inset inside its content column, on *both* sides. @@ -4060,7 +4069,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti /* Desktop keeps navigation compact and settings beside the collection. */ @media (min-width: 64rem) { .shelf { --shelf-measure: var(--collection-measure); } - .shelf-head, .shelf-discovery, .shelf-section { width: calc(100% - 3rem); } + .shelf-head, .shelf-discovery, .shelf-section { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); } .shelf-head { padding-top: 2rem; padding-bottom: 1.25rem; } .shelf-discovery { display: flex; align-items: center; gap: 1.5rem; max-width: var(--shelf-measure); margin: 0 auto 1rem; padding-inline: .9rem; } .shelf-discovery .shelf-links { flex: none; margin: 0; padding: 0; } @@ -4069,18 +4078,18 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .collection-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem 2rem; padding-top: 2rem; } .collection-header .collection-links { margin-top: 0; } .collection-header .manager-crumbs { grid-column: 1 / -1; } - .collection-header, .library-browse-body, .library-files-screen .manager-body { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; } + .collection-header, .library-browse-body, .library-files-screen .manager-body { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); max-width: var(--collection-measure); margin-inline: auto; } .library-files-screen .manager-row { grid-template-columns: 24px minmax(0, 1fr) minmax(15rem, 22rem) 44px; min-height: 64px; padding-block: .5rem; } .library-files-screen .manager-check { grid-row: 1; } .library-files-screen .manager-meta { grid-column: 3; grid-row: 1; justify-content: flex-end; padding-left: 0; } .library-files-screen .manager-more { grid-column: 4; grid-row: 1; } .shelf-settings.panel { inset: 1rem 1rem auto auto; width: 26rem; height: min(42rem, calc(100dvh - 2rem)); max-height: calc(100dvh - 2rem); border: 1px solid var(--reader-line); border-radius: 1rem; box-shadow: 0 12px 48px #0003; overflow: hidden; animation-name: shelf-settings-in; } .shelf-settings .switch-field { grid-template-columns: minmax(0, 1fr) auto; } - .sources-header { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; padding: 2rem .9rem .75rem; border-bottom: 0; } + .sources-header { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); max-width: var(--collection-measure); margin-inline: auto; padding: 2rem .9rem .75rem; border-bottom: 0; } .sources-header h1 { font-size: 1.5rem; font-weight: 650; } .sources-hub .sources-tabs [role=tab] { flex: 0 1 auto; min-width: 9rem; padding-inline: 1.5rem; } .extension-tabs button { min-width: 7rem; padding-inline: 1.5rem; } - .sources-hub .sources-body, .extension-screen .sources-body, .sources-hub .sources-tabs, .extension-toolbar { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; padding-inline: .9rem; } + .sources-hub .sources-body, .extension-screen .sources-body, .sources-hub .sources-tabs, .extension-toolbar { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); max-width: var(--collection-measure); margin-inline: auto; padding-inline: .9rem; } .sources-body .sources-card { max-width: none; } .reader-screen { --reader-footer-height: 5.5rem; } .reader-screen { --desktop-page-width: 52rem; } diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index 0248832..bc062b9 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -655,7 +655,7 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr if (state.speechUnavailable) { return (
- 当前环境没有可用的朗读引擎。请管理员在「系统设置 → 服务配置 → HTTP 朗读」配置语音服务,或启用设备的系统语音。 + 当前环境没有可用的朗读引擎。请管理员在「我的 → 系统设置 → 服务配置 → HTTP 朗读」配置语音服务,或启用设备的系统语音。
); } diff --git a/web/test/media-settings.test.tsx b/web/test/media-settings.test.tsx index 224b31d..6d3e861 100644 --- a/web/test/media-settings.test.tsx +++ b/web/test/media-settings.test.tsx @@ -20,7 +20,7 @@ it('isolates device preferences by server/account and leaves reading storage unt }); it('saves explicit changes and reports storage errors without applying them',async()=>{ const onSaved=vi.fn(); - await act(async()=>render(,root)); + await act(async()=>render(,root)); act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('浏览偏好'))!.click()); const select=root.querySelector('select')!; act(()=>{select.value='comfortable';select.dispatchEvent(new Event('change',{bubbles:true}));}); @@ -34,7 +34,7 @@ it('saves explicit changes and reports storage errors without applying them',asy }); it('uses the active player for live playback options and exposes admin management',async()=>{ const speed=vi.fn(),sleep=vi.fn(),onManage=vi.fn(); - await act(async()=>render(,root)); + await act(async()=>render(,root)); act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('播放设置'))!.click()); const current=root.querySelector('[aria-label="播放倍速"]')!; act(()=>{current.value='1.5';current.dispatchEvent(new Event('change',{bubbles:true}));Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='30分钟')!.click();}); @@ -43,13 +43,13 @@ it('uses the active player for live playback options and exposes admin managemen act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('媒体库管理'))!.click());expect(onManage).toHaveBeenCalled(); }); it('keeps member navigation usable without exposing administrator actions',async()=>{ - const onPersonal=vi.fn(),onBack=vi.fn(); - await act(async()=>render(,root)); + const onGeneral=vi.fn(),onBack=vi.fn(); + await act(async()=>render(,root)); expect(root.textContent).not.toContain('媒体库管理'); - for(const [title,view] of [['我的收藏','favorites'],['播放历史','history']]){ - act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith(title!))!.click()); - expect(onPersonal).toHaveBeenLastCalledWith(view); - } + // App-wide settings live in 我的; the media panel links there instead of repeating them. + expect(root.textContent).not.toContain('外观与主题');expect(root.textContent).not.toContain('我的收藏'); + act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('外观、账号与系统设置'))!.click()); + expect(onGeneral).toHaveBeenCalled(); act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('播放设置'))!.click()); expect(root.querySelector('[aria-label="默认倍速"]')).not.toBeNull();expect(root.textContent).toContain('连续播放'); act(()=>root.querySelector('[aria-label="返回影音设置"]')!.click());expect(onBack).not.toHaveBeenCalled(); @@ -57,13 +57,13 @@ it('keeps member navigation usable without exposing administrator actions',async it('shows account identity without exposing credentials or administrator source controls to a member',async()=>{ const api=new MediaApi({baseUrl:'https://reader.example',currentSession:()=>({user:{username:'lin',displayName:'林间',role:'member'},accessToken:'secret-access',refreshToken:'secret-refresh'})} as unknown as ReaderApi); expect(api.accountInfo()).toEqual({username:'lin',displayName:'林间',role:'member',server:'https://reader.example'}); - await act(async()=>render(,root)); + await act(async()=>render(,root)); expect(root.textContent).not.toContain('来源与刮削'); - act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('账号与连接'))!.click()); + await act(async()=>render(,root)); expect(root.textContent).toContain('@lin');expect(root.textContent).toContain('https://reader.example');expect(root.textContent).not.toContain('secret'); }); it('asks before dropping an unsaved preference draft',async()=>{ - await act(async()=>render(,root)); + await act(async()=>render(,root)); act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('浏览偏好'))!.click()); act(()=>{const select=root.querySelector('select')!;select.value='comfortable';select.dispatchEvent(new Event('change',{bubbles:true}));}); act(()=>root.querySelector('[aria-label="返回影音设置"]')!.click()); @@ -77,7 +77,7 @@ it('asks before dropping an unsaved preference draft',async()=>{ }); it('retains an unsaved draft through route back/forward and discards it only explicitly',async()=>{ const onPanelChange=vi.fn();let panel:'home'|'browse'='browse'; - const draw=()=>render(,root); + const draw=()=>render(,root); await act(async()=>draw()); act(()=>{const select=root.querySelector('select')!;select.value='comfortable';select.dispatchEvent(new Event('change',{bubbles:true}));}); act(()=>{panel='home';draw();}); @@ -91,7 +91,7 @@ it('retains an unsaved draft through route back/forward and discards it only exp act(()=>{panel='browse';draw();});expect(root.querySelector('select')!.value).toBe('compact'); }); it('does not show playback settings when a member follows the provider deep link',async()=>{ - await act(async()=>render(,root)); + await act(async()=>render(,root)); expect(root.querySelector('[role=alert]')?.textContent).toContain('需要管理员权限'); expect(root.textContent).not.toContain('还没有正在播放'); }); diff --git a/web/test/mine-page.test.tsx b/web/test/mine-page.test.tsx index 4c98a46..b52f5f1 100644 --- a/web/test/mine-page.test.tsx +++ b/web/test/mine-page.test.tsx @@ -42,17 +42,31 @@ describe('我的', () => { const links = [...root.querySelectorAll('a.mine-row')].map(a => [a.textContent, a.getAttribute('href')]); expect(links).toEqual([ ['书库', '/library'], ['书源', '/sources'], - ['外观与主题跟随系统', '/media/video/settings/theme'], ['播放', '/media/video/settings/playback'], - ['账号与连接', '/media/video/settings/account'], + ['外观与主题跟随系统', '/me/theme'], ['影音浏览紧凑', '/me/browse'], ['播放', '/me/playback'], + ['账号与连接', '/me/account'], ]); + // Members see no administration group. + expect(root.querySelector('[aria-label="管理"]')).toBeNull(); }); - it('offers media library management only to administrators and shows the chosen theme', () => { + it('opens the shelf display sheet in place', () => { + useAuthStore.setState({ verifiedUser: user('member') }); + paint(); + const sheet = () => root.querySelector('[role=dialog][aria-label="书架设置"]')!; + expect(sheet().hidden).toBe(true); + act(() => { [...root.querySelectorAll('button.mine-row')].find(b => b.textContent?.startsWith('书架显示'))!.click(); }); + expect(sheet().hidden).toBe(false); + }); + + it('gathers administration under 管理 for administrators and shows the chosen theme', () => { saveThemePreference('mine-test', { theme: 'blue', mode: 'dark' }); useAuthStore.setState({ verifiedUser: user('admin') }); paint(); const labels = [...root.querySelectorAll('.mine-row-label')].map(el => el.textContent); expect(labels).toContain('媒体库'); + const admin = [...root.querySelectorAll('[aria-label="管理"] .mine-row-label')].map(el => el.textContent); + expect(admin).toEqual(['系统设置', '扫描与刮削', '来源与刮削']); + expect(root.querySelector('[aria-label="管理"] a[href="/me/plugins"]')).not.toBeNull(); expect(root.textContent).toContain('雾蓝 · 深色'); expect(root.querySelector('.mine-profile')?.textContent).toContain('管理员'); }); diff --git a/web/tools/business-settings-smoke.cjs b/web/tools/business-settings-smoke.cjs index d2f352b..74b4d62 100644 --- a/web/tools/business-settings-smoke.cjs +++ b/web/tools/business-settings-smoke.cjs @@ -24,7 +24,7 @@ const assert=require('node:assert/strict'); const token=(await result.json()).session.accessToken,headers={authorization:'Bearer '+token,'content-type':'application/json'}; browser=await chromium.launch({headless:true});const page=await browser.newPage({viewport:{width:390,height:844}}),errors=[];page.on('pageerror',error=>errors.push(error.message)); await page.goto(origin);await page.locator('input[autocomplete="username"]').fill('settings-admin');await page.locator('input[type="password"]').fill('settings-test-password');await page.locator('form button[type=submit]').click(); - async function open(){await page.getByRole('button',{name:'系统设置',exact:true}).click();await page.getByRole('button',{name:'服务配置',exact:true}).click();await page.getByLabel('配置分类').selectOption('tts');} + async function open(){await page.goto(origin+'/#/me');await page.getByRole('button',{name:/^系统设置/}).click();await page.getByRole('button',{name:'服务配置',exact:true}).click();await page.getByLabel('配置分类').selectOption('tts');} await open();await page.getByLabel('启用 HTTP 朗读',{exact:true}).check();await page.getByLabel('语音合成接口',{exact:true}).fill(tts+'/tts');await page.getByLabel('音色列表接口(可选)',{exact:true}).fill(tts+'/voices'); await page.getByRole('button',{name:'检测连接并获取音色',exact:true}).click();await page.locator('#business-tts-voices option').waitFor({state:'attached'}); await page.getByRole('button',{name:'生成试听音频',exact:true}).click();await page.locator('audio[aria-label="HTTP 朗读试听"]').waitFor(); diff --git a/web/tools/media-theme-review.cjs b/web/tools/media-theme-review.cjs index a081f41..637f102 100644 --- a/web/tools/media-theme-review.cjs +++ b/web/tools/media-theme-review.cjs @@ -10,15 +10,15 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo const go=async hash=>page.evaluate(hash=>location.hash=hash,hash); const palette=selector=>page.locator(selector).evaluate(node=>({bg:getComputedStyle(node).getPropertyValue('--media-background').trim(),ink:getComputedStyle(node).getPropertyValue('--text').trim(),scheme:getComputedStyle(node).colorScheme})); await page.goto(baseUrl+'/#/media/music/settings');await page.locator('input[autocomplete="username"]').fill('reviewer');await page.locator('input[type="password"]').fill('review-test-pass');await page.locator('form button[type=submit]').click(); - await page.getByRole('button',{name:/主题外观/}).click();await page.getByRole('heading',{name:'主题外观',exact:true}).waitFor();assert.match(page.url(),/music\/settings\/theme/); - await shot('themes-mobile');await page.getByRole('button',{name:'返回影音设置',exact:true}).click();await page.getByRole('heading',{name:'影音设置',exact:true}).waitFor();await page.getByRole('button',{name:/主题外观/}).click(); + await page.goto(baseUrl+'/#/me');await page.getByRole('link',{name:/外观与主题/}).click();await page.getByRole('heading',{name:'外观与主题',exact:true}).waitFor();assert.match(page.url(),/#\/me\/theme/); + await shot('themes-mobile');await page.getByRole('button',{name:'返回我的',exact:true}).click();await page.getByRole('heading',{name:'我的',exact:true}).waitFor();await page.getByRole('link',{name:/外观与主题/}).click(); const readingTheme=await page.evaluate(()=>document.documentElement.getAttribute('data-theme')); for(const [id,name] of [['forest','原野'],['sand','暖纸'],['ocean','雾蓝'],['rose','蔷薇'],['midnight','深夜'],['graphite','石墨']]){ await page.getByRole('button',{name,exact:true}).click();assert.equal(await page.getByRole('button',{name,exact:true}).getAttribute('aria-pressed'),'true'); assert.equal(await page.locator('body').getAttribute('data-media-theme'),id); await go('#/media/music/albums');await page.locator('.media-tile').first().waitFor();await shot('channel-'+id); assert.deepEqual(await palette('.media-screen'),await palette('.media-channel-entry')); - await go('#/media/music/settings/theme');await page.getByRole('heading',{name:'主题外观',exact:true}).waitFor(); + await go('#/media/music/settings/theme');await page.getByRole('heading',{name:'外观与主题',exact:true}).waitFor(); } await page.getByRole('button',{name:'深夜',exact:true}).click();await page.reload();await page.getByRole('button',{name:'深夜',exact:true}).waitFor();assert.equal(await page.getByRole('button',{name:'深夜',exact:true}).getAttribute('aria-pressed'),'true'); await shot('themes-dark-mobile');await page.setViewportSize({width:1120,height:900});await shot('themes-desktop');await page.setViewportSize({width:427,height:900});