mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
feat(shell): one page column for the four tabs, 我的 as the settings hub
- 首页/书架/影音/我的 share --page-measure and --page-gutter, so content edges stay put across tabs at every width; 我的 lays its groups two per row on wide screens. - 我的 gathers every setting (偏好 / 管理 / 账号); its panels live at /me/:panel and return to 我的. - 影音设置 keeps only media settings and links to 我的; 来源与刮削 is now reachable. - 系统设置 moves from the shelf header to 我的 → 管理.
This commit is contained in:
1 parent
f15365d03c
commit
1f534ee4c3
17 files changed
+189
-97
No files matched your search
+2
-1
@@ -97,7 +97,8 @@
|
||||
|
||||
### 3.2 一列,封顶
|
||||
|
||||
内容列封顶 68rem(`--shelf-measure`),宽屏加的是留白而不是更多列。
|
||||
首页、书架、影音、我的共用一条内容列:内容宽度封顶 `--page-measure`(`--collection-measure` 80rem 减去书架带内边距,约 1251px),
|
||||
两侧留白 `--page-gutter`(手机 16px,侧栏模式 32px),切换标签页时内容边缘不动。宽屏加的是留白而不是更多列。
|
||||
1600px 窗口排十一个封面、每个书名十二个字,比手机上的三列**更难扫**——
|
||||
这与「多出来的宽度是为了好读」正好相反。
|
||||
|
||||
|
||||
@@ -63,7 +63,12 @@ export const routes = [{
|
||||
{ path: 'settings', element: <Navigate to="/shelf" replace /> },
|
||||
{ path: 'book/:bookId', element: <ReaderPage /> },
|
||||
// 我的 shares MediaLayout so it follows the chosen theme and keeps the channel rail.
|
||||
{ path: 'me', element: <MediaLayout />, children: [{ index: true, element: <MinePage /> }] },
|
||||
// Its settings rows open /me/:panel (theme, browse, playback, plugins, account),
|
||||
// so back from any of them returns to 我的.
|
||||
{ path: 'me', element: <MediaLayout />, children: [
|
||||
{ index: true, element: <MinePage /> },
|
||||
{ path: ':panel', element: <MediaSettingsPage hub="me" /> },
|
||||
] },
|
||||
{ path: 'media', element: <MediaLayout />, children: [
|
||||
{ index: true, element: <Navigate to="video" replace /> },
|
||||
{ path: 'music', element: <Navigate to="/media/music/albums" replace /> },
|
||||
|
||||
+32
-2
@@ -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. */
|
||||
|
||||
@@ -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 = <>
|
||||
<span className="mine-row-icon" aria-hidden="true"><Icon size={18} /></span>
|
||||
<span className="mine-row-label">{label}</span>
|
||||
@@ -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
|
||||
? <Link className={className} to={to}>{body}</Link>
|
||||
? <Link className={className} to={to} state={state}>{body}</Link>
|
||||
: <button type="button" className={className} onClick={onClick}>{body}</button>;
|
||||
}
|
||||
|
||||
@@ -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 <div className="media-screen media-page mine-page">
|
||||
<header className="home-header"><h1>我的</h1></header>
|
||||
{user && <section className="mine-profile" aria-label="当前账号">
|
||||
<div className="mine-avatar" aria-hidden="true">{Array.from(name)[0]}</div>
|
||||
<div className="mine-profile-text">
|
||||
<strong>{name}</strong>
|
||||
<small><span className="mine-dot" aria-hidden="true" />{serverHost(runtime.api.baseUrl)} · {admin ? '管理员' : '成员'}</small>
|
||||
</div>
|
||||
</section>}
|
||||
<Group title="内容">
|
||||
<Row icon={LibraryBig} label="书库" to="/library" />
|
||||
<Row icon={Rss} label="书源" to="/sources" />
|
||||
{admin && <Row icon={Film} label="媒体库" to="/media/video/settings/libraries" />}
|
||||
</Group>
|
||||
<Group title="偏好">
|
||||
<Row icon={Palette} label="外观与主题" value={themeName} to="/media/video/settings/theme" />
|
||||
<Row icon={PlayCircle} label="播放" to="/media/video/settings/playback" />
|
||||
</Group>
|
||||
<Group title="账号">
|
||||
<Row icon={UserRound} label="账号与连接" to="/media/video/settings/account" />
|
||||
<Row icon={LogOut} label="退出登录" danger onClick={() => { void runtime.api.signOut(); }} />
|
||||
</Group>
|
||||
</div>;
|
||||
return <>
|
||||
<div className="media-screen media-page mine-page">
|
||||
<header className="home-header"><h1>我的</h1></header>
|
||||
{user && <section className="mine-profile" aria-label="当前账号">
|
||||
<div className="mine-avatar" aria-hidden="true">{Array.from(name)[0]}</div>
|
||||
<div className="mine-profile-text">
|
||||
<strong>{name}</strong>
|
||||
<small><span className="mine-dot" aria-hidden="true" />{serverHost(runtime.api.baseUrl)} · {admin ? '管理员' : '成员'}</small>
|
||||
</div>
|
||||
</section>}
|
||||
<Group title="内容">
|
||||
<Row icon={LibraryBig} label="书库" to="/library" />
|
||||
<Row icon={Rss} label="书源" to="/sources" />
|
||||
{admin && <Row icon={Film} label="媒体库" to="/media/video/settings/libraries" state={{ returnTo: '/me' }} />}
|
||||
</Group>
|
||||
<Group title="偏好">
|
||||
<Row icon={Palette} label="外观与主题" value={themeName} to="/me/theme" />
|
||||
<Row icon={LayoutGrid} label="书架显示" value={DENSITY_LABELS[settings.shelfDensity]} onClick={() => setSheet('shelf')} />
|
||||
<Row icon={SlidersHorizontal} label="影音浏览" value={mediaDensity} to="/me/browse" />
|
||||
<Row icon={PlayCircle} label="播放" to="/me/playback" />
|
||||
</Group>
|
||||
{admin && <Group title="管理">
|
||||
<Row icon={Settings} label="系统设置" value="用户、注册、服务配置" onClick={() => setSheet('system')} />
|
||||
<Row icon={RefreshCw} label="扫描与刮削" to="/media/video/settings/tasks" state={{ returnTo: '/me' }} />
|
||||
<Row icon={Puzzle} label="来源与刮削" to="/me/plugins" />
|
||||
</Group>}
|
||||
<Group title="账号">
|
||||
<Row icon={UserRound} label="账号与连接" to="/me/account" />
|
||||
<Row icon={LogOut} label="退出登录" danger onClick={() => { void runtime.api.signOut(); }} />
|
||||
</Group>
|
||||
</div>
|
||||
{/* Outside the media screen, so its sheet styles are the shelf's own, not media's. */}
|
||||
<ShelfSettingsPanel open={sheet === 'shelf'} settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} />
|
||||
{sheet === 'opds' && <OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} onClose={() => setSheet('shelf')} />}
|
||||
{sheet === 'system' && <SystemSettings api={runtime.api} onClose={() => setSheet(null)} />}
|
||||
</>;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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<AppSettings>)=>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<AppSettings>)=>void;backLabel?:string}){
|
||||
const [localPanel,setLocalPanel]=useState<SettingsPanel>('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<MediaPreferences>){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)=><button className="media-setting-row" onClick={onClick}><Icon size={19} strokeWidth={1.6} aria-hidden="true"/><span>{label}</span><small>{description}</small><ChevronRight size={16} aria-hidden="true"/></button>;
|
||||
return <section className="media-settings" aria-label="影音设置">
|
||||
<div className="media-settings-heading"><button className="media-back-button" data-media-back aria-label={panel==='home'?'← 返回影音':'返回影音设置'} title={panel==='home'?'返回影音':'返回影音设置'} onClick={back}><ChevronLeft size={20} aria-hidden="true"/></button><h1>{title}</h1></div>
|
||||
<div className="media-settings-heading"><button className="media-back-button" data-media-back aria-label={panel==='home'?'← 返回影音':backLabel} title={panel==='home'?'返回影音':backLabel} onClick={back}><ChevronLeft size={20} aria-hidden="true"/></button><h1>{title}</h1></div>
|
||||
{leaving&&<FloatingConfirm theme="media" title="放弃浏览偏好修改?" text="浏览偏好尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);setDraft({...preferences});setPanel('home');}}/>}
|
||||
{panel==='home'?<>
|
||||
{account&&<div className="media-settings-intro"><div className="media-account-avatar" aria-hidden="true">{Array.from(account.displayName||account.username)[0]}</div><div><strong>{account.displayName||account.username}</strong><small>{account.role==='admin'?'管理员':'普通用户'} · 当前服务器</small></div></div>}
|
||||
<section className="media-settings-group"><h2>我的内容</h2>
|
||||
{link(Heart,'我的收藏','',()=>onPersonal('favorites'))}
|
||||
{link(Clock3,'播放历史','',()=>onPersonal('history'))}
|
||||
</section><section className="media-settings-group"><h2>偏好</h2>
|
||||
{link(Palette,'主题外观',themeSummary(themePreference),()=>setPanel('theme'))}
|
||||
<section className="media-settings-group"><h2>偏好</h2>
|
||||
{link(SlidersHorizontal,'浏览偏好',preferences.density==='compact'?'紧凑':'宽松',openBrowse)}
|
||||
{link(Headphones,'播放设置','倍速与连续播放',()=>setPanel('playback'))}
|
||||
</section>
|
||||
{admin&&<section className="media-settings-group"><h2>管理员</h2>{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}</section>}
|
||||
{account&&<section className="media-settings-group"><h2>账号</h2>{link(UserRound,'账号与连接','',()=>setPanel('account'))}</section>}
|
||||
{admin&&<section className="media-settings-group"><h2>管理员</h2>{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}{_api&&link(Puzzle,'来源与刮削','TMDB、MusicBrainz 等资料来源',()=>setPanel('plugins'))}</section>}
|
||||
{onGeneral&&<section className="media-settings-group"><h2>通用</h2>{link(UserRound,'外观、账号与系统设置','在「我的」中管理',onGeneral)}</section>}
|
||||
</>:panel==='theme'?<MediaThemeSettings scope={scope} {...(onReaderSettings?{onReaderSettings}:{})}/>:panel==='browse'?<form className="media-form media-settings-panel" onSubmit={event=>{event.preventDefault();save();}}>
|
||||
<p>仅影响影音页面,在当前设备为此服务器和账号保存。</p>
|
||||
<label className="media-setting-field">封面密度<MediaSelect aria-label="封面密度" value={draft.density} onChange={event=>change({density:event.currentTarget.value as MediaPreferences['density']})}><option value="compact">紧凑(手机三列)</option><option value="comfortable">宽松(手机两列)</option></MediaSelect></label>
|
||||
|
||||
@@ -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 <MediaPageFrame className="media-manager-workspace" title="媒体库管理" backTo={`/media/${channel}/settings`} backLabel="返回影音设置" actions={<details className="media-actions media-library-management-actions">
|
||||
return <MediaPageFrame className="media-manager-workspace" title="媒体库管理" backTo={fromMine ? '/me' : `/media/${channel}/settings`} backLabel={fromMine ? '返回我的' : '返回影音设置'} actions={<details className="media-actions media-library-management-actions">
|
||||
<summary aria-label="媒体库管理操作" title="媒体库管理操作"><MoreHorizontal size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label="媒体库管理操作">
|
||||
<Link to={`/media/${channel}/settings/libraries/new`}><Plus size={16} aria-hidden="true" />新建媒体库</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { MediaPageFrame } from '../components/MediaPageFrame.tsx';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { MediaSettings } from '../components/settings.tsx';
|
||||
@@ -9,7 +9,7 @@ import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
|
||||
const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'plugins', 'account'];
|
||||
const titles: Record<SettingsPanel, string> = {
|
||||
home: '影音设置', theme: '主题外观', browse: '浏览偏好', playback: '播放设置',
|
||||
home: '影音设置', theme: '外观与主题', browse: '浏览偏好', playback: '播放设置',
|
||||
plugins: '来源与刮削', account: '账号与连接',
|
||||
};
|
||||
|
||||
@@ -17,7 +17,13 @@ function safePanel(value: string | undefined): SettingsPanel {
|
||||
return value && panels.includes(value as SettingsPanel) ? value as SettingsPanel : 'home';
|
||||
}
|
||||
|
||||
export function MediaSettingsPage() {
|
||||
/**
|
||||
* One settings panel, reached from either of two places:
|
||||
* - `/media/:channel/settings[/:panel]`, the media channel's own settings (⋯ → 影音设置);
|
||||
* - `/me/:panel` (`hub="me"`), a row of 我的. Back returns to 我的, and there is no
|
||||
* media home panel under /me, because 我的 *is* that list.
|
||||
*/
|
||||
export function MediaSettingsPage({ hub }: { hub?: 'me' }) {
|
||||
const runtime = useRuntime();
|
||||
const navigate = useNavigate();
|
||||
const { channel: routeChannel, panel: routePanel } = useParams();
|
||||
@@ -25,11 +31,14 @@ export function MediaSettingsPage() {
|
||||
const panel = safePanel(routePanel);
|
||||
const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
const [preferences, setPreferences] = useState(() => readMediaPreferences(runtime.mediaApi.preferenceScope()));
|
||||
if (hub === 'me' && panel === 'home') return <Navigate to="/me" replace />;
|
||||
const home = hub === 'me' ? '/me' : `/media/${channel}/settings`;
|
||||
const settingsPath = (next: SettingsPanel) => next === 'home'
|
||||
? `/media/${channel}/settings`
|
||||
: `/media/${channel}/settings/${next}`;
|
||||
? home
|
||||
: hub === 'me' ? `/me/${next}` : `/media/${channel}/settings/${next}`;
|
||||
const backLabel = hub === 'me' ? '返回我的' : '返回影音设置';
|
||||
|
||||
return <MediaPageFrame className="media-utility-workspace" title={titles[panel]} {...(panel !== 'home' ? { backTo: `/media/${channel}/settings`, backLabel: '返回影音设置' } : {})}>
|
||||
return <MediaPageFrame className="media-utility-workspace" title={titles[panel]} {...(panel !== 'home' ? { backTo: home, backLabel } : {})}>
|
||||
<MediaSettings
|
||||
scope={runtime.mediaApi.preferenceScope()}
|
||||
preferences={preferences}
|
||||
@@ -38,13 +47,14 @@ export function MediaSettingsPage() {
|
||||
api={runtime.mediaApi}
|
||||
account={runtime.mediaApi.accountInfo()}
|
||||
panel={panel}
|
||||
backLabel={backLabel}
|
||||
onSaved={setPreferences}
|
||||
onBack={() => navigate(`/media/${channel}`)}
|
||||
onPanelChange={next => navigate(settingsPath(next))}
|
||||
onPersonal={view => navigate(view === 'favorites' ? '/media/favorites' : view === 'queue' ? `/media/${channel}/queue` : `/media/${channel}/history`, { state: { returnTo: `/media/${channel}/settings` } })}
|
||||
onManage={() => navigate(`/media/${channel}/settings/libraries`)}
|
||||
onTasks={() => navigate(`/media/${channel}/settings/tasks`, { state: { returnTo: `/media/${channel}/settings` } })}
|
||||
onPlayback={() => navigate(`/media/${channel}/player`)}
|
||||
onGeneral={() => navigate('/me')}
|
||||
onReaderSettings={patch => { void runtime.updateSettings(patch); }}
|
||||
/>
|
||||
</MediaPageFrame>;
|
||||
|
||||
@@ -27,10 +27,11 @@ export function MediaTasksPage() {
|
||||
const [actionNotice, setActionNotice] = useState('');
|
||||
const tab = params.get('tab') === 'ai' || params.get('tab') === 'scrape' ? params.get('tab')! : 'scan';
|
||||
const requestedLibraryId = params.get('library') ?? '';
|
||||
const returnTo = typeof location.state?.returnTo === 'string' && location.state.returnTo.startsWith('/media/')
|
||||
? location.state.returnTo
|
||||
const stateReturn = location.state?.returnTo;
|
||||
const returnTo = typeof stateReturn === 'string' && (stateReturn.startsWith('/media/') || stateReturn === '/me')
|
||||
? stateReturn
|
||||
: `/media/${channel}/settings`;
|
||||
const returnLabel = returnTo.includes('/settings/libraries') ? '返回媒体库管理' : '返回影音设置';
|
||||
const returnLabel = returnTo === '/me' ? '返回我的' : returnTo.includes('/settings/libraries') ? '返回媒体库管理' : '返回影音设置';
|
||||
const libraries = useQuery(librariesQuery(runtime));
|
||||
const libraryId = libraries.data?.items.some(item => item.id === requestedLibraryId)
|
||||
? requestedLibraryId
|
||||
|
||||
@@ -2,15 +2,13 @@ import { Alert } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { BookOpen, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical } from 'lucide-react';
|
||||
import { BookOpen, LibraryBig, MoreHorizontal, Search, SlidersVertical } from 'lucide-react';
|
||||
import type { Book, ContinueReadingItem, ReadingState } from '../../../api/types.ts';
|
||||
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 { useShelf, useShelfGroups, type ShelfFilters } from '../queries/shelf.queries.ts';
|
||||
import { SHELF_SORTS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx';
|
||||
import { OpdsAccess } from '../../../ui/opds-access.tsx';
|
||||
import { SystemSettings } from '../../../ui/system-settings.tsx';
|
||||
import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
@@ -61,7 +59,6 @@ export function ShelfPage() {
|
||||
const runtime = useRuntime();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useSettingsStore(state => state.settings);
|
||||
const user = useAuthStore(state => state.verifiedUser);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const page = Math.max(1, Number(params.get('page')) || 1);
|
||||
const [sort, setSort] = useState<typeof settings.shelfSort>((params.get('sort') as typeof settings.shelfSort) || settings.shelfSort);
|
||||
@@ -69,7 +66,6 @@ export function ShelfPage() {
|
||||
const [queryText, setQueryText] = useState(params.get('q') || '');
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [opdsOpen, setOpdsOpen] = useState(false);
|
||||
const [systemSettingsOpen, setSystemSettingsOpen] = useState(false);
|
||||
const [removeTarget, setRemoveTarget] = useState<Book | ContinueReadingItem | null>(null);
|
||||
const [menuTarget, setMenuTarget] = useState<Book | null>(null);
|
||||
const [groupsOpen, setGroupsOpen] = useState(false);
|
||||
@@ -120,7 +116,6 @@ export function ShelfPage() {
|
||||
<header className="shelf-head">
|
||||
<div className="shelf-head-text"><h1 className="shelf-title">{queryText ? '搜索结果' : '书架'}</h1><p className="shelf-subtitle muted">{subtitle}</p></div>
|
||||
<div className="shelf-head-actions">
|
||||
{user?.role === 'admin' && <button className="icon-button" aria-label="系统设置" title="系统设置" onClick={() => setSystemSettingsOpen(true)}><Settings className="icon" size="1em" strokeWidth={1.8} /><span className="media-visually-hidden">系统设置</span></button>}
|
||||
<button className="icon-button" aria-label={`书架设置 · ${densityLabel}`} title={`书架设置 · ${densityLabel}`} onClick={() => setSettingsOpen(true)}><SlidersVertical className="icon" size="1em" strokeWidth={1.8} /><span className="media-visually-hidden">设置</span></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -156,7 +151,6 @@ export function ShelfPage() {
|
||||
</section>
|
||||
<ShelfSettingsPanel open={settingsOpen} settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} />
|
||||
{opdsOpen && <OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} onClose={() => { setOpdsOpen(false); setSettingsOpen(true); }} />}
|
||||
{systemSettingsOpen && <SystemSettings api={runtime.api} onClose={() => setSystemSettingsOpen(false)} />}
|
||||
{menuTarget && <DialogView dialog={{ kind: 'pick', title: `《${menuTarget.title || '未命名'}》`, options: menuOptions(menuTarget), resolve: (_value: string | null) => undefined } satisfies Dialog} destructive="从书架拿掉" onClose={answer => { const target = menuTarget; setMenuTarget(null); if (typeof answer === 'string' && answer) void act(target, answer as BookAction); }} />}
|
||||
{groupsOpen && <DialogView dialog={{ kind: 'pick', title: groups.isPending ? '分组 · 正在读取…' : groups.error ? '分组 · 读取失败' : '分组', options: groupOptions, resolve: (_value: string | null) => undefined } satisfies Dialog} destructive="" onClose={answer => {
|
||||
setGroupsOpen(false);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -655,7 +655,7 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr
|
||||
if (state.speechUnavailable) {
|
||||
return (
|
||||
<div className="notice">
|
||||
当前环境没有可用的朗读引擎。请管理员在「系统设置 → 服务配置 → HTTP 朗读」配置语音服务,或启用设备的系统语音。
|
||||
当前环境没有可用的朗读引擎。请管理员在「我的 → 系统设置 → 服务配置 → HTTP 朗读」配置语音服务,或启用设备的系统语音。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(<MediaSettings scope="test" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={onSaved} onBack={vi.fn()} onPersonal={vi.fn()} onManage={vi.fn()}/>,root));
|
||||
await act(async()=>render(<MediaSettings scope="test" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={onSaved} onBack={vi.fn()} onManage={vi.fn()}/>,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(<MediaSettings scope="test" preferences={defaultMediaPreferences} player={{active:true,playbackRate:1,sleepAt:0,speed,sleep} as unknown as MediaPlayer} admin onSaved={vi.fn()} onBack={vi.fn()} onPersonal={vi.fn()} onManage={onManage}/>,root));
|
||||
await act(async()=>render(<MediaSettings scope="test" preferences={defaultMediaPreferences} player={{active:true,playbackRate:1,sleepAt:0,speed,sleep} as unknown as MediaPlayer} admin onSaved={vi.fn()} onBack={vi.fn()} onManage={onManage}/>,root));
|
||||
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('播放设置'))!.click());
|
||||
const current=root.querySelector<HTMLSelectElement>('[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(<MediaSettings scope="member" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={onBack} onPersonal={onPersonal} onManage={vi.fn()}/>,root));
|
||||
const onGeneral=vi.fn(),onBack=vi.fn();
|
||||
await act(async()=>render(<MediaSettings scope="member" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={onBack} onManage={vi.fn()} onGeneral={onGeneral}/>,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<HTMLButtonElement>('[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(<MediaSettings scope="member" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} api={api} account={api.accountInfo()} onSaved={vi.fn()} onBack={vi.fn()} onPersonal={vi.fn()} onManage={vi.fn()}/>,root));
|
||||
await act(async()=>render(<MediaSettings scope="member" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} api={api} account={api.accountInfo()} onSaved={vi.fn()} onBack={vi.fn()} onManage={vi.fn()}/>,root));
|
||||
expect(root.textContent).not.toContain('来源与刮削');
|
||||
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('账号与连接'))!.click());
|
||||
await act(async()=>render(<MediaSettings scope="member" panel="account" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} api={api} account={api.accountInfo()} onSaved={vi.fn()} onBack={vi.fn()} onManage={vi.fn()}/>,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(<MediaSettings scope="draft" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={vi.fn()} onPersonal={vi.fn()} onManage={vi.fn()}/>,root));
|
||||
await act(async()=>render(<MediaSettings scope="draft" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={vi.fn()} onManage={vi.fn()}/>,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<HTMLButtonElement>('[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(<MediaSettings scope="route-draft" panel={panel} onPanelChange={onPanelChange} preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={vi.fn()} onPersonal={vi.fn()} onManage={vi.fn()}/>,root);
|
||||
const draw=()=>render(<MediaSettings scope="route-draft" panel={panel} onPanelChange={onPanelChange} preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={vi.fn()} onManage={vi.fn()}/>,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(<MediaSettings scope="member" panel="plugins" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={vi.fn()} onPersonal={vi.fn()} onManage={vi.fn()}/>,root));
|
||||
await act(async()=>render(<MediaSettings scope="member" panel="plugins" preferences={defaultMediaPreferences} player={{active:false} as MediaPlayer} admin={false} onSaved={vi.fn()} onBack={vi.fn()} onManage={vi.fn()}/>,root));
|
||||
expect(root.querySelector('[role=alert]')?.textContent).toContain('需要管理员权限');
|
||||
expect(root.textContent).not.toContain('还没有正在播放');
|
||||
});
|
||||
@@ -42,17 +42,31 @@ describe('我的', () => {
|
||||
const links = [...root.querySelectorAll<HTMLAnchorElement>('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<HTMLElement>('[role=dialog][aria-label="书架设置"]')!;
|
||||
expect(sheet().hidden).toBe(true);
|
||||
act(() => { [...root.querySelectorAll<HTMLButtonElement>('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('管理员');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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});
|
||||
|
||||
Reference in new issue
Block a user