feat(account): 我的 page at /me

Account card (name, server, role), links to the pages that own library,
sources, media libraries, theme, playback and account settings, and sign out.
Reached from the channel navigation and the shelf header. Follows the media
theme tokens. Fixes the channel-current check so /media/* is not mistaken for /me.
This commit is contained in:
Claude authored and hectorqin committed 2026-10-19 22:33:26 +08:00
1 parent fdd3a70401
commit f232787213
7 files changed
+238 -6

No files matched your search

+3 -1
View File
@@ -40,7 +40,9 @@ export function AuthBoundary({ children }: { children?: ReactNode }) {
export function MediaLayout() {
const runtime = useRuntime();
const location = useLocation();
const current = location.pathname.startsWith('/media/music')
const current = location.pathname === '/me' || location.pathname.startsWith('/me/')
? 'me'
: location.pathname.startsWith('/media/music')
? 'music'
: location.pathname.startsWith('/media/audiobook')
? 'audiobook'
+3
View File
@@ -13,6 +13,7 @@ import { ShelfPage } from '../../features/shelf/pages/ShelfPage.tsx';
import { LibraryPage, LibraryBrowsePage, LibraryFilesPage } from '../../features/library/pages/LibraryPage.tsx';
import { SourcesPage } from '../../features/sources/pages/SourcesPage.tsx';
import { ReaderPage } from '../../features/reader/pages/ReaderPage.tsx';
import { MinePage } from '../../features/account/pages/MinePage.tsx';
import { MediaLibrariesPage, MediaLibraryCreatePage } from '../../features/media/pages/MediaLibrariesPage.tsx';
import { MediaLibraryEditPage, MediaLibraryPermissionsPage } from '../../features/media/pages/MediaLibraryEditorPages.tsx';
import { MediaTasksPage } from '../../features/media/pages/MediaTasksPage.tsx';
@@ -58,6 +59,8 @@ export const routes = [{
// the shelf settings sheet and media settings has its own nested route.
{ 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 /> }] },
{ path: 'media', element: <MediaLayout />, children: [
{ index: true, element: <Navigate to="video" replace /> },
{ path: 'music', element: <Navigate to="/media/music/albums" replace /> },
@@ -0,0 +1,70 @@
import { Link } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Film, LibraryBig, LogOut, Palette, PlayCircle, Rss, UserRound, type LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { mediaThemes, readMediaTheme } from '../../media/services/theme.ts';
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 }) {
const body = <>
<span className="mine-row-icon" aria-hidden="true"><Icon size={18} /></span>
<span className="mine-row-label">{label}</span>
{value && <span className="mine-row-value">{value}</span>}
{!danger && <ChevronRight size={16} aria-hidden="true" className="mine-row-chevron" />}
</>;
const className = danger ? 'mine-row is-danger' : 'mine-row';
return to
? <Link className={className} to={to}>{body}</Link>
: <button type="button" className={className} onClick={onClick}>{body}</button>;
}
function Group({ title, children }: { title: string; children: ReactNode }) {
return <section className="mine-group" aria-label={title}><h2>{title}</h2><div className="mine-card">{children}</div></section>;
}
function serverHost(baseUrl: string): string {
try { return new URL(baseUrl).host; } catch { return baseUrl; }
}
/**
* 我的: 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.
*/
export function MinePage() {
const runtime = useRuntime();
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
const admin = user?.role === 'admin';
const choice = readMediaTheme(runtime.mediaApi.preferenceScope());
const themeName = choice === 'system' ? '跟随系统' : (mediaThemes.find(theme => theme.id === choice)?.name ?? '跟随系统');
const name = user?.displayName || user?.username || '';
return <div className="media-screen media-page mine-page">
<header className="media-heading">
<div className="media-page-heading">
<Link className="media-back-button" to="/shelf" aria-label="← 返回" title="返回书架"><ChevronLeft size={20} aria-hidden="true" /></Link>
<h1>我的</h1>
</div>
</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>;
}
+83
View File
@@ -0,0 +1,83 @@
/* 我的: reuses the media theme tokens (--media-theme-*) so it follows the chosen theme. */
.mine-page { max-width: 640px; }
.mine-page .media-back-button { color: inherit; text-decoration: none; }
.mine-profile {
display: flex;
align-items: center;
gap: 14px;
margin: 16px 0 8px;
padding: 16px;
border-radius: 20px;
background: var(--media-accent);
color: var(--media-on-accent);
}
.mine-avatar {
display: grid;
place-items: center;
flex: none;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--media-on-accent);
color: var(--media-accent);
font-size: 24px;
font-weight: 700;
}
.mine-profile-text { min-width: 0; }
.mine-profile-text strong { display: block; font-size: 18px; }
.mine-profile-text small { display: flex; align-items: center; gap: 6px; margin-top: 2px; font-size: 12px; opacity: 0.85; overflow-wrap: anywhere; }
.mine-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.mine-group { margin: 20px 0 0; }
.mine-group > h2 { margin: 0 0 6px 4px; font-size: 12px; font-weight: 600; letter-spacing: 0.08em; color: var(--muted); }
.mine-card {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--media-paper, var(--surface));
}
.mine-page .mine-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 52px;
padding: 0 14px;
box-sizing: border-box;
border: 0;
border-top: 1px solid var(--border);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.mine-page .mine-row:first-child { border-top: 0; }
.mine-page .mine-row:hover { background: var(--surface); }
.mine-page .mine-row:focus-visible { outline: 2px solid var(--media-accent); outline-offset: -2px; }
.mine-row-icon {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--media-selection);
color: var(--media-accent);
}
.mine-row-label { flex: 1; font-size: 15px; font-weight: 500; }
.mine-row-value { font-size: 13px; color: var(--muted); }
.mine-row-chevron { flex: none; color: var(--muted); }
.mine-page .mine-row.is-danger { color: var(--media-error-ink); }
.mine-page .mine-row.is-danger .mine-row-icon { background: var(--media-error-background); color: var(--media-error-ink); }
@@ -1,5 +1,5 @@
import { render } from '../../../shared/ui/render-root.ts';
import { BookOpen, Clapperboard, Music2, Headphones, ChevronsUpDown, Check } from 'lucide-react';
import { BookOpen, Clapperboard, Music2, Headphones, UserRound, ChevronsUpDown, Check } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
@@ -8,19 +8,22 @@ const channels = [
{ id: 'video', label: '影视', icon: Clapperboard },
{ id: 'music', label: '音乐', icon: Music2 },
{ id: 'audiobook', label: '有声书', icon: Headphones },
{ id: 'me', label: '我的', icon: UserRound },
];
/** '' is reading (the shelf), 'me' is the account hub, anything else is a media channel. */
export const channelPath = (id: string) => id === '' ? '/shelf' : id === 'me' ? '/me' : `/media/${id}`;
export function ChannelSwitcher({current,label}:{current:string;label?:string}) {
const root=useRef<HTMLDetailsElement>(null);
useEffect(()=>{const close=(event:PointerEvent)=>{if(root.current&&!root.current.contains(event.target as Node))root.current.open=false;};document.addEventListener('pointerdown',close);return()=>document.removeEventListener('pointerdown',close);},[]);
return <details className="media-channel-switcher" ref={root} onKeyDown={event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();root.current!.open=false;root.current!.querySelector('summary')?.focus();}}}>
<summary aria-label="切换频道" title="切换频道">{label&&<strong>{label}</strong>}<ChevronsUpDown size={18} aria-hidden="true"/></summary>
<nav aria-label="切换内容频道">{channels.map(({id,label,icon:Icon})=><a key={id} href={id?'#/media/'+id:'#/shelf'} aria-current={id===current?'page':undefined} onClick={()=>{if(root.current)root.current.open=false;}}><Icon size={18} aria-hidden="true"/><span>{label}</span>{id===current&&<Check size={16} aria-hidden="true"/>}</a>)}</nav>
<nav aria-label="切换内容频道">{channels.map(({id,label,icon:Icon})=><a key={id} href={'#'+channelPath(id)} aria-current={id===current?'page':undefined} onClick={()=>{if(root.current)root.current.open=false;}}><Icon size={18} aria-hidden="true"/><span>{label}</span>{id===current&&<Check size={16} aria-hidden="true"/>}</a>)}</nav>
</details>;
}
export function MediaChannelEntry({ current }: { current: string }) {
return <nav className="media-channel-entry" aria-label="内容频道">
<span className="media-brand" aria-hidden="true">reader.</span>{channels.map(({ id, label, icon: Glyph }) =>
<Link key={id} to={id ? `/media/${id}` : '/shelf'} aria-current={current === id ? 'page' : undefined}>
<Link key={id} to={channelPath(id)} aria-current={current === id ? 'page' : undefined}>
<span className="media-channel-icon"><Glyph size={22} strokeWidth={1.8} aria-hidden="true" /></span>
<span>{label}</span>
</Link>)}
@@ -28,7 +31,7 @@ export function MediaChannelEntry({ current }: { current: string }) {
}
export function renderChannelLinks(element: HTMLElement, current: string) {
render(<><span className="media-brand" aria-hidden="true">reader.</span>{channels.map(({ id, label, icon: Glyph }) =>
<a key={id} href={id ? '#/media/' + id : '#/shelf'} aria-current={current === id ? 'page' : undefined}>
<a key={id} href={'#' + channelPath(id)} aria-current={current === id ? 'page' : undefined}>
<span className="media-channel-icon"><Glyph size={22} strokeWidth={1.8} aria-hidden="true" /></span>
<span>{label}</span>
</a>)}</>, element);
+2 -1
View File
@@ -2,7 +2,7 @@ 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, Headphones, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical } from 'lucide-react';
import { BookOpen, Headphones, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical, UserRound } from 'lucide-react';
import type { Book, ContinueReadingItem } from '../../../api/types.ts';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
@@ -80,6 +80,7 @@ export function ShelfPage() {
<div className="shelf-head-text"><h1 className="shelf-title">{queryText ? '搜索结果' : '我的书架'}</h1><p className="shelf-subtitle muted">{subtitle}</p></div>
<div className="shelf-head-actions">
<Link className="icon-button" aria-label="影音" title="影音" to="/media/video"><Headphones className="icon" size="1em" strokeWidth={1.8} /></Link>
<Link className="icon-button" aria-label="我的" title="我的" to="/me"><UserRound className="icon" size="1em" strokeWidth={1.8} /></Link>
{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>
+70
View File
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render } from '../src/shared/ui/render-root.ts';
import { MediaLayout } from '../src/app/App.tsx';
import { MinePage } from '../src/features/account/pages/MinePage.tsx';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
import { saveMediaTheme } from '../src/features/media/services/theme.ts';
const root = document.createElement('div');
document.body.append(root);
const signOut = vi.fn(async () => undefined);
const runtime = {
api: { baseUrl: 'https://reader.example.com/app', signOut },
mediaApi: { preferenceScope: () => 'mine-test' },
} as any;
const user = (role: 'admin' | 'member') => ({ id: 'u', username: 'lin', displayName: '林间', role, createdAt: 0 });
const paint = () => act(() => render(
<RuntimeContext.Provider value={runtime}>
<MemoryRouter initialEntries={['/me']}>
<Routes><Route path="/me" element={<MediaLayout />}><Route index element={<MinePage />} /></Route></Routes>
</MemoryRouter>
</RuntimeContext.Provider>,
root,
));
afterEach(() => {
act(() => render(null, root));
useAuthStore.setState({ session: null, verifiedUser: null });
localStorage.clear();
signOut.mockClear();
});
describe('我的', () => {
it('shows who is signed in, which server, and links to the pages that own each setting', () => {
useAuthStore.setState({ verifiedUser: user('member') });
paint();
expect(root.querySelector('.mine-profile')?.textContent).toContain('林间');
expect(root.querySelector('.mine-profile')?.textContent).toContain('reader.example.com');
expect(root.querySelector('.mine-profile')?.textContent).toContain('成员');
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'],
]);
});
it('offers media library management only to administrators and shows the chosen theme', () => {
saveMediaTheme('mine-test', 'midnight');
useAuthStore.setState({ verifiedUser: user('admin') });
paint();
const labels = [...root.querySelectorAll('.mine-row-label')].map(el => el.textContent);
expect(labels).toContain('媒体库');
expect(root.textContent).toContain('深夜');
expect(root.querySelector('.mine-profile')?.textContent).toContain('管理员');
});
it('signs out through the API and keeps the channel rail with 我的 marked current', () => {
useAuthStore.setState({ verifiedUser: user('member') });
paint();
act(() => { root.querySelector<HTMLButtonElement>('.mine-row.is-danger')!.click(); });
expect(signOut).toHaveBeenCalledTimes(1);
const current = root.querySelector('nav.media-channel-entry a[aria-current="page"]');
expect(current?.textContent).toContain('我的');
expect(current?.getAttribute('href')).toBe('/me');
});
});