fix(web): source row actions use the ⋯ dropdown instead of expanding

The 我的来源 "管理" button and collapsible extension items (e.g. the
Legado source list) expanded an inline panel that changed the row; the
search options were a hand-built menu without Esc/outside-click handling.
All three now use ActionMenu, as docs/ui.md requires for row actions:
- 我的来源: plugin pages, 基本设置, 设为/取消默认, 暂停/启用.
- Extension items: a form without fields is a menu action (confirmed
  first when it asks), a form with fields opens in a Sheet.
- 搜索选项: 继续搜索 / 重新搜索 keep their explanation via a new
  MenuAction.description, exposed with aria-describedby.
Source rows keep one layout at every width now that nothing expands;
dead expansion and search-menu styles are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-29 03:27:45 +00:00
1 parent 03bb3e753a
commit 59bb4306fc
8 files changed
+71 -55

No files matched your search

@@ -5,7 +5,8 @@ import type { ExtensionContent, ExtensionField, ExtensionForm, ExtensionPage } f
import { ApiError } from '../../../api/errors.ts';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import { ActionMenu } from '../../../ui/action-menu.tsx';
import { ConfirmSheet, Sheet } from '../../../ui/sheet.tsx';
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
import { Button } from '../../../ui/toolkit.tsx';
import { SourcesHeader } from '../components/SourcesHeader.tsx';
@@ -71,12 +72,12 @@ function FormView({ form, formKey, values, busy, selected, onChange, onSubmit, o
</form>;
}
function ContentView({ content, scope, values, busy, selected, expanded, onChange, onSubmit, onSelectAction, onToggle, onToggleSelected }: {
content: ExtensionContent; scope: string; values: Record<string, FormValues>; busy: boolean; selected: Set<string>; expanded: Set<string>;
function ContentView({ content, scope, values, busy, selected, onChange, onSubmit, onSelectAction, onEdit, onToggleSelected }: {
content: ExtensionContent; scope: string; values: Record<string, FormValues>; busy: boolean; selected: Set<string>;
onChange(scope: string, key: string, value: unknown): void;
onSubmit(form: ExtensionForm, values: FormValues, formKey: string): void;
onSelectAction(field: ExtensionField, value: string, form: ExtensionForm, formKey: string): void;
onToggle(key: string): void; onToggleSelected(key: string): void;
onEdit(form: ExtensionForm, formKey: string, title: string): void; onToggleSelected(key: string): void;
}) {
const editor = <>
{content.links?.filter(link => safeExternalUrl(link.url)).map(link => <a className="button" key={link.url} href={link.url} target="_blank" rel="noopener noreferrer">{link.title}</a>)}
@@ -86,12 +87,18 @@ function ContentView({ content, scope, values, busy, selected, expanded, onChang
{content.sections?.map((section, sectionIndex) => <section className="extension-section" key={`${scope}:section:${sectionIndex}`}>
<h2>{section.title}</h2>
{!section.items.length && section.emptyText && <p className="extension-empty">{section.emptyText}</p>}
{section.items.map((item, itemIndex) => { const key = `${scope}:item:${sectionIndex}:${itemIndex}`; const open = expanded.has(key); return <article className="sources-card extension-item" key={key}>
{section.items.map((item, itemIndex) => { const key = `${scope}:item:${sectionIndex}:${itemIndex}`; const formKey = (form: ExtensionForm) => `${key}:form:${form.id}`;
// A collapsible item's forms are its row actions: a ⋯ menu, never an in-place expansion.
// A form with fields opens in a Sheet; a bare button form submits (confirming first if asked).
const menu = item.collapsible ? (item.forms ?? []).map(form => form.fields.length
? { label: form.title || form.submit, disabled: busy, onSelect: () => onEdit(form, formKey(form), item.title) }
: { label: form.submit, danger: !!form.confirm, disabled: busy, onSelect: () => onSubmit(form, values[formKey(form)] ?? initialValues(form), formKey(form)) }) : [];
return <article className="sources-card extension-item" key={key}>
<div className="extension-item-heading"><div>
{item.selectable && item.key && <input type="checkbox" aria-label={`选择 ${item.title}`} checked={selected.has(item.key)} onChange={() => onToggleSelected(item.key!)} />}
<h3>{item.title}</h3>{item.description && <p className="source-description">{item.description}</p>}
</div>{item.collapsible && <button className="button" type="button" aria-expanded={open} aria-controls={`extension-item-${key}`} onClick={() => onToggle(key)}>{open ? '收起' : '管理'}</button>}</div>
<div id={`extension-item-${key}`} hidden={item.collapsible && !open} className="extension-actions">{item.forms?.map(form => { const formKey = `${key}:form:${form.id}`; return <FormView key={formKey} form={form} formKey={formKey} values={values[formKey] ?? initialValues(form)} busy={busy} selected={selected} onChange={(field, value) => onChange(formKey, field, value)} onSubmit={onSubmit} onSelectAction={onSelectAction} />; })}</div>
</div>{item.collapsible && <ActionMenu text="管理" label="管理" title={`${item.title}管理`} items={menu} />}</div>
{!item.collapsible && !!item.forms?.length && <div className="extension-actions">{item.forms.map(form => <FormView key={formKey(form)} form={form} formKey={formKey(form)} values={values[formKey(form)] ?? initialValues(form)} busy={busy} selected={selected} onChange={(field, value) => onChange(formKey(form), field, value)} onSubmit={onSubmit} onSelectAction={onSelectAction} />)}</div>}
</article>; })}
</section>)}
{content.outputs?.map(output => <details className="sources-card extension-output" open key={`${scope}:output:${output.title}`}><summary>{output.title}</summary><pre tabIndex={0} aria-label={output.title} data-format={output.format}>{output.text}</pre></details>)}
@@ -106,17 +113,16 @@ export function SourceExtensionPage() {
const pageQuery = useQuery({ queryKey, queryFn: () => sourceId ? api.sourcePage(sourceId, pageId) : api.pluginPage(pluginId!, pageId), enabled: Boolean(pageId && (sourceId || admin)) });
const action = useMutation({ mutationFn: ({ id, values }: { id?: string; values?: FormValues }) => sourceId ? api.sourcePage(sourceId, pageId, id, values) : api.pluginPage(pluginId!, pageId, id, values) });
const page = pageQuery.data as ExtensionPage | undefined; const [activeTab, setActiveTab] = useState('');
const [values, setValues] = useState<Record<string, FormValues>>({}); const [selected, setSelected] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [values, setValues] = useState<Record<string, FormValues>>({}); const [selected, setSelected] = useState<Set<string>>(new Set()); const [editing, setEditing] = useState<{ form: ExtensionForm; key: string; title: string } | null>(null);
const [error, setError] = useState(''); const [noticeTab, setNoticeTab] = useState(''); const [confirmation, setConfirmation] = useState<{ form: ExtensionForm; values: FormValues; key: string } | null>(null); const busy = action.isPending;
const tabs = page?.tabs ?? []; const selectedTab = useMemo(() => tabs.find(tab => tab.id === activeTab), [tabs, activeTab]);
useEffect(() => { setActiveTab(''); setValues({}); setSelected(new Set()); setExpanded(new Set()); setError(''); setNoticeTab(''); setConfirmation(null); }, [owner, pageId]);
useEffect(() => { setActiveTab(''); setValues({}); setSelected(new Set()); setEditing(null); setError(''); setNoticeTab(''); setConfirmation(null); }, [owner, pageId]);
useEffect(() => { if (!page) return; setActiveTab(current => page.activeTab && !current ? page.activeTab : (current && tabs.some(tab => tab.id === current) ? current : tabs[0]?.id ?? '')); setNoticeTab(current => current || page.activeTab || tabs[0]?.id || ''); }, [page, tabs]);
const updateValue = (key: string, field: string, value: unknown) => setValues(previous => {
const form = findForm(page, key);
const base = previous[key] ?? (form ? initialValues(form) : {});
return { ...previous, [key]: { ...base, [field]: value } };
});
const toggleExpanded = (key: string) => setExpanded(previous => { const next = new Set(previous); if (next.has(key)) next.delete(key); else next.add(key); return next; });
const toggleSelected = (key: string) => setSelected(previous => { const next = new Set(previous); if (next.has(key)) next.delete(key); else next.add(key); return next; });
const execute = async (id?: string, payload?: FormValues, submittedKey?: string, submittedForm?: ExtensionForm) => {
setError('');
@@ -127,6 +133,7 @@ export function SourceExtensionPage() {
const next = await action.mutateAsync(request);
queryClient.setQueryData(queryKey, next);
if (submittedKey) setValues(previous => { const copy = { ...previous }; delete copy[submittedKey]; return copy; });
if (submittedKey) setEditing(current => current?.key === submittedKey ? null : current);
setNoticeTab(activeTab);
setConfirmation(null);
return true;
@@ -153,9 +160,11 @@ export function SourceExtensionPage() {
{tabs.length > 0 && <div className="extension-toolbar"><div className="extension-tabs" role="tablist" aria-label="配置分类">{tabs.map((tab, index) => <button type="button" key={tab.id} id={`extension-tab-${tab.id}`} role="tab" aria-selected={activeTab === tab.id} aria-controls={`extension-panel-${tab.id}`} tabIndex={activeTab === tab.id ? 0 : -1} disabled={busy} onClick={() => { setActiveTab(tab.id); if (tab.loadAction) void execute(tab.loadAction); }} onKeyDown={event => { let next = index; if (event.key === 'ArrowRight') next = (index + 1) % tabs.length; else if (event.key === 'ArrowLeft') next = (index + tabs.length - 1) % tabs.length; else if (event.key === 'Home') next = 0; else if (event.key === 'End') next = tabs.length - 1; else return; event.preventDefault(); const nextTab = tabs[next]!; setActiveTab(nextTab.id); document.getElementById(`extension-tab-${nextTab.id}`)?.focus({ preventScroll: true }); }}>{tab.title}</button>)}</div></div>}
{status && <FloatingNotice message={status} busy={busy} error={statusError} kind="success" />}
<main className="sources-body" aria-busy={busy} tabIndex={-1}>{page.description && <p className="extension-description">{page.description}</p>}
<ContentView content={page} scope="page" values={values} busy={busy} selected={selected} expanded={expanded} onChange={updateValue.bind(null)} onSubmit={submit} onSelectAction={selectAction} onToggle={toggleExpanded} onToggleSelected={toggleSelected} />
{selectedTab && <div role="tabpanel" id={`extension-panel-${selectedTab.id}`} aria-labelledby={`extension-tab-${selectedTab.id}`} tabIndex={-1}>{selectedTab.description && <p className="extension-description">{selectedTab.description}</p>}<ContentView content={selectedTab} scope={`tab:${selectedTab.id}`} values={values} busy={busy} selected={selected} expanded={expanded} onChange={updateValue.bind(null)} onSubmit={submit} onSelectAction={selectAction} onToggle={toggleExpanded} onToggleSelected={toggleSelected} /></div>}
</main>{confirmation && <ConfirmSheet title="确认操作" text={confirmation.form.confirm ?? ''} confirmText={`确认${confirmation.form.submit}`} cancelText="取消" onCancel={() => setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />}
<ContentView content={page} scope="page" values={values} busy={busy} selected={selected} onChange={updateValue.bind(null)} onSubmit={submit} onSelectAction={selectAction} onEdit={(form, key, title) => setEditing({ form, key, title })} onToggleSelected={toggleSelected} />
{selectedTab && <div role="tabpanel" id={`extension-panel-${selectedTab.id}`} aria-labelledby={`extension-tab-${selectedTab.id}`} tabIndex={-1}>{selectedTab.description && <p className="extension-description">{selectedTab.description}</p>}<ContentView content={selectedTab} scope={`tab:${selectedTab.id}`} values={values} busy={busy} selected={selected} onChange={updateValue.bind(null)} onSubmit={submit} onSelectAction={selectAction} onEdit={(form, key, title) => setEditing({ form, key, title })} onToggleSelected={toggleSelected} /></div>}
</main>{editing && <Sheet title={editing.title} subtitle={editing.form.title} busy={busy} onClose={() => setEditing(null)}>
<div className="extension-sheet-form"><FormView form={{ ...editing.form, title: '' }} formKey={editing.key} values={values[editing.key] ?? initialValues(editing.form)} busy={busy} selected={selected} onChange={(field, value) => updateValue(editing.key, field, value)} onSubmit={submit} onSelectAction={selectAction} /></div>
</Sheet>}{confirmation && <ConfirmSheet title="确认操作" text={confirmation.form.confirm ?? ''} confirmText={`确认${confirmation.form.submit}`} cancelText="取消" onCancel={() => setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />}
</div>;
}
+13 -6
View File
@@ -1,4 +1,5 @@
import { Sheet } from '../../../ui/sheet.tsx';
import { ActionMenu } from '../../../ui/action-menu.tsx';
import { Button, Callout, IconTextButton } from '../../../ui/toolkit.tsx';
import { SourcesHeader } from '../components/SourcesHeader.tsx';
import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx';
@@ -28,14 +29,12 @@ export function SourcesPage() {
const [tab, setTab] = useState<SourceTab>(useLocation().pathname === '/sources/plugins' ? 'plugins' : 'search');
const [sourceId, setSourceId] = useState('');
const [keyword, setKeyword] = useState('');
const [managing, setManaging] = useState<string | null>(null);
const [catalogPage, setCatalogPage] = useState<SourcePage>();
const [catalogError, setCatalogError] = useState('');
const [catalogBusy, setCatalogBusy] = useState(false);
const [catalogFilters, setCatalogFilters] = useState<Record<string, string>>({});
const [detail, setDetail] = useState<SourceEntry | null>(null);
const [searchState, setSearchState] = useState<SearchState>('idle');
const [searchMenu, setSearchMenu] = useState(false);
const [resultGroupKey, setResultGroupKey] = useState<string | null>(null);
const [resultGroupQuery, setResultGroupQuery] = useState('');
const [sourceNameQuery, setSourceNameQuery] = useState('');
@@ -81,7 +80,7 @@ export function SourcesPage() {
if (!request?.query || !selectedSource || catalogBusy || (append && !canResume)) return;
const run = new AbortController(); searchRun.current = run; ++catalogEpoch.current;
const current = () => searchRun.current === run;
setCatalogBusy(true); setCatalogError(''); setSearchState('searching'); setSearchMenu(false);
setCatalogBusy(true); setCatalogError(''); setSearchState('searching');
if (!append) { setCatalogPage(undefined); searchSession.current = crypto.randomUUID(); setAcquired(null); setResultGroupKey(null); }
searchRequest.current = request;
try {
@@ -97,7 +96,7 @@ export function SourcesPage() {
};
const acquireEntry = (entry: SourceEntry, option?: string) => acquire.mutate(option === undefined ? { ref: entry.ref } : { ref: entry.ref, option }, { onSuccess: result => { if (result.kind === 'action-required') setCatalogError(result.action?.label ?? '请先完成授权'); else if (result.publicationId) { setAcquired({ id: result.publicationId, ref: entry.ref, title: entry.title }); void queryClient.invalidateQueries({ queryKey: ['shelf'] }); } } });
const mutateSubscription = async (id: string, enabled?: boolean) => { setBusy(id); try { if (enabled === undefined) await runtime.api.refreshPublication(id); else await runtime.api.configureSubscription(id, { enabled }); await queryClient.invalidateQueries({ queryKey: ['subscriptions'] }); } finally { setBusy(null); } };
const updateSource = async (id: string, patch: { enabled?: boolean; isDefault?: boolean }) => { setBusy(id); try { await runtime.api.saveSource(id, patch); await sources.refetch(); setManaging(null); } finally { setBusy(null); } };
const updateSource = async (id: string, patch: { enabled?: boolean; isDefault?: boolean }) => { setBusy(id); try { await runtime.api.saveSource(id, patch); await sources.refetch(); } finally { setBusy(null); } };
useEffect(() => {
++catalogEpoch.current; searchRun.current?.abort(); searchRun.current = null;
setCatalogBusy(false); setCatalogPage(undefined); setCatalogError(''); setCatalogFilters({}); setDetail(null); searchSession.current = null;
@@ -125,7 +124,10 @@ export function SourcesPage() {
{selectedSource.descriptor?.capabilities.includes('search') && <form className="sources-search" onSubmit={event => { event.preventDefault(); void searchSources(resume); }}>
{sourceFilters.data?.filter(field => field.type === 'select').map(field => <SearchableSelect key={field.key} label={field.label} value={catalogFilters[field.key] ?? ''} disabled={catalogBusy} options={filterOptions(field)} onChange={value => setCatalogFilters(previous => { const next = { ...previous, [field.key]: value }; for (const dependent of sourceFilters.data ?? []) { if ((dependent.changeAction ?? dependent.dependsOn) === field.key && !dependent.options?.some(option => option.value === next[dependent.key] && (!option.parentValues || !value || option.parentValues.includes(value)))) next[dependent.key] = ''; } return next; })} />)}
<label className="source-keyword">搜索书籍<input type="search" placeholder="输入书名或作者" required disabled={catalogBusy} value={keyword} onChange={event => setKeyword(event.currentTarget.value)} /></label>
{searchState === 'searching' ? <button className="button search-stop" type="button" onClick={stopSearch}>停止搜索</button> : <div className="search-split"><button className="button primary" type="submit" disabled={catalogBusy}><Search size={16} />{resume ? '继续搜索' : '搜索'}</button>{searchRequest.current && <><button className="button primary search-menu-toggle" type="button" aria-label="搜索选项" aria-haspopup="menu" aria-expanded={searchMenu} onClick={() => setSearchMenu(open => !open)}><ChevronRight size={16} /></button>{searchMenu && <div className="search-menu" role="menu" aria-label="搜索选项"><button type="button" role="menuitem" disabled={!canResume} onClick={() => void searchSources(true)}>继续搜索<small>保留结果,继续未完成的来源</small></button><button type="button" role="menuitem" onClick={() => void searchSources(false)}>重新搜索<small>清空结果,从头搜索</small></button></div>}</>}</div>}
{searchState === 'searching' ? <button className="button search-stop" type="button" onClick={stopSearch}>停止搜索</button> : <div className="search-split"><button className="button primary" type="submit" disabled={catalogBusy}><Search size={16} />{resume ? '继续搜索' : '搜索'}</button>{searchRequest.current && <ActionMenu className="button primary search-menu-toggle" label="搜索选项" items={[
{ label: '继续搜索', description: '保留结果,继续未完成的来源', disabled: !canResume, onSelect: () => void searchSources(true) },
{ label: '重新搜索', description: '清空结果,从头搜索', onSelect: () => void searchSources(false) },
]} />}</div>}
</form>}
{searchState !== 'idle' && <div className="search-progress" role="status"><span>{({ searching: '正在搜索', stopped: '已停止搜索', complete: '搜索完成', error: '搜索中断', limited: '已达到结果上限' })[searchState]}{catalogPage?.batch ? ` · 已检查 ${catalogPage.batch.completed} / ${catalogPage.batch.total} 个来源` : ''} · 已找到 {results.length} 本书</span>{catalogPage?.batch && <progress aria-label="书源搜索进度" max={Math.max(1, catalogPage.batch.total)} value={catalogPage.batch.completed} />}</div>}
{catalogPage && grouped && <label className="catalog-group-filter">筛选分组<input type="search" placeholder="搜索书名或作者" value={resultGroupQuery} onChange={event => setResultGroupQuery(event.currentTarget.value)} /></label>}
@@ -141,7 +143,12 @@ export function SourcesPage() {
{tab === 'sources' && <section className="sources-card sources-list">
<div className="sources-list-heading"><h2>我的来源 <span className="source-count">{sources.data?.length ?? 0}</span></h2>{admin && <Link className="button source-add" to="/sources/manage/new"><Plus size={16} />添加来源</Link>}</div>
{sources.isPending && <FloatingNotice message="正在加载书源…" busy />}{sources.error && <p className="notice error">{sources.error.message}</p>}{!sources.isPending && !sources.data?.length && <p className="muted">还没有配置书源。请添加来源或启用插件。</p>}
{(sources.data ?? []).map(source => <article className="sources-row source-entry" key={source.id}><div className="source-entry-top"><div className="source-entry-info"><strong>{source.name}{source.isDefault && <span className="source-default-badge">默认</span>}</strong><small>{source.descriptor?.label ?? sourceTypeLabel(source.sourceType)}</small><span className={'source-state' + (source.enabled ? ' is-enabled' : '')}>{source.enabled ? '已启用' : '已暂停'}</span></div><div className="source-entry-actions">{source.descriptor?.credentialKeys?.length ? <Link className="button" to={'/sources/' + encodeURIComponent(source.id) + '/credentials'}>书源登录</Link> : null}{admin && <button className="source-manage-trigger" type="button" aria-expanded={managing === source.id} onClick={() => setManaging(current => current === source.id ? null : source.id)}>管理<ChevronRight size={16} /></button>}</div></div>{admin && managing === source.id && <div className="source-management" role="group" aria-label={`${source.name}管理`}>{source.descriptor?.extensions?.pages?.map(page => <Link className="source-management-link" key={page.id} to={'/sources/' + encodeURIComponent(source.id) + '/' + encodeURIComponent(page.id)}>{page.title}<ChevronRight size={16} /></Link>)}<Link className="source-management-link" to={'/sources/manage/' + encodeURIComponent(source.id)}>基本设置<ChevronRight size={16} /></Link><button className="source-management-link" type="button" disabled={busy === source.id || (!source.isDefault && (!source.enabled || !source.descriptor?.capabilities.includes('search')))} onClick={() => void updateSource(source.id, { isDefault: !source.isDefault })}>{source.isDefault ? '取消默认' : '设为默认'}</button><button className="source-management-link source-toggle" type="button" disabled={busy === source.id} onClick={() => void updateSource(source.id, { enabled: !source.enabled })}>{source.enabled ? '暂停' : '启用'}</button></div>}</article>)}
{(sources.data ?? []).map(source => <article className="sources-row source-entry" key={source.id}><div className="source-entry-top"><div className="source-entry-info"><strong>{source.name}{source.isDefault && <span className="source-default-badge">默认</span>}</strong><small>{source.descriptor?.label ?? sourceTypeLabel(source.sourceType)}</small><span className={'source-state' + (source.enabled ? ' is-enabled' : '')}>{source.enabled ? '已启用' : '已暂停'}</span></div><div className="source-entry-actions">{source.descriptor?.credentialKeys?.length ? <Link className="button" to={'/sources/' + encodeURIComponent(source.id) + '/credentials'}>书源登录</Link> : null}{admin && <ActionMenu text="管理" label="管理" title={`${source.name}管理`} items={[
...(source.descriptor?.extensions?.pages ?? []).map(page => ({ label: page.title, onSelect: () => navigate('/sources/' + encodeURIComponent(source.id) + '/' + encodeURIComponent(page.id)) })),
{ label: '基本设置', onSelect: () => navigate('/sources/manage/' + encodeURIComponent(source.id)) },
{ label: source.isDefault ? '取消默认' : '设为默认', disabled: busy === source.id || (!source.isDefault && (!source.enabled || !source.descriptor?.capabilities.includes('search'))), onSelect: () => void updateSource(source.id, { isDefault: !source.isDefault }) },
{ label: source.enabled ? '暂停' : '启用', disabled: busy === source.id, onSelect: () => void updateSource(source.id, { enabled: !source.enabled }) },
]} />}</div></div></article>)}
</section>}
{tab === 'updates' && <section className="sources-card"><h2>自动追更</h2><p className="notice">服务端定时检查目录,浏览器关闭后继续运行。正文和图片在阅读时缓存。</p>{subscriptions.isPending && <FloatingNotice message="正在加载追更列表…" busy />}{!subscriptions.isPending && !subscriptions.data?.length && <p>先从章节书源加入书籍,再开启追更。</p>}{(subscriptions.data ?? []).map(item => <article className="sources-row" key={item.bookId}><div><strong>{item.title}</strong><small>{item.newChapters ? '新增 ' + item.newChapters + ' 章 · ' : ''}上次成功:{item.lastSuccessAt ? new Date(item.lastSuccessAt).toLocaleString() : '尚未检查'}</small><small>{item.enabled ? '下次检查:' + new Date(item.nextCheckAt).toLocaleString() : '自动追更已关闭'}{item.lastError ? ' · 检查失败:' + item.lastError : ''}</small></div><div className="sources-actions"><label className="source-interval">检查间隔<select aria-label={`${item.title}检查间隔`} value={item.intervalMinutes} disabled={busy === item.bookId} onChange={event => { const intervalMinutes = Number(event.currentTarget.value); setBusy(item.bookId); void runtime.api.configureSubscription(item.bookId, { intervalMinutes }).then(() => queryClient.invalidateQueries({ queryKey: ['subscriptions'] })).finally(() => setBusy(null)); }}><option value="15">15 分钟</option><option value="60">1 小时</option><option value="360">6 小时</option><option value="1440">1 天</option></select></label><button className="button" type="button" disabled={busy === item.bookId} onClick={() => void mutateSubscription(item.bookId, !item.enabled)}>{item.enabled ? '关闭追更' : '开启追更'}</button><button className="button" type="button" disabled={busy === item.bookId} onClick={() => void mutateSubscription(item.bookId)}>立即检查</button><Link className="button" to={'/book/' + encodeURIComponent(item.bookId)}>阅读</Link></div></article>)}</section>}
{tab === 'plugins' && (admin ? <PluginManager /> : <Callout tone="warning">只有管理员可以管理插件</Callout>)}
+8
View File
@@ -88,6 +88,14 @@
text-overflow: ellipsis;
}
.action-menu-description {
display: block;
color: var(--color-muted);
font-size: var(--text-meta);
line-height: 1.4;
white-space: normal;
}
.action-menu-check {
flex: none;
}
+11 -32
View File
@@ -3609,8 +3609,9 @@ book-content {
.sources-body { overflow:auto; padding:0 1rem 2rem; flex:1; }
.sources-card { max-width:64rem; margin:0 auto 1rem; padding:1rem; border:1px solid var(--reader-line); border-radius:var(--radius-lg); }
.sources-card h2 { margin:.25rem 0 1rem; font-size:var(--text-title); }
.sources-card label { display:flex; flex-direction:column; gap:.4rem; margin:.75rem 0; }
.sources-card input:not([type=checkbox]), .sources-card select, .sources-card textarea { width:100%; box-sizing:border-box; padding:.65rem; border:1px solid var(--reader-line); border-radius:var(--radius-sm); background:var(--reader-surface); color:inherit; font:inherit; }
.sources-card label, .extension-sheet-form label { display:flex; flex-direction:column; gap:.4rem; margin:.75rem 0; }
.sources-card input:not([type=checkbox]), .sources-card select, .sources-card textarea,
.extension-sheet-form input:not([type=checkbox]), .extension-sheet-form select, .extension-sheet-form textarea { width:100%; box-sizing:border-box; padding:.65rem; border:1px solid var(--reader-line); border-radius:var(--radius-sm); background:var(--reader-surface); color:inherit; font:inherit; }
.sources-card textarea { min-height:6rem; }
.sources-row { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; justify-content:space-between; padding:1rem 0; border-top:1px solid var(--reader-line); }
.sources-row > div:first-child { flex:1 1 15rem; min-width:0; overflow-wrap:anywhere; }
@@ -3638,12 +3639,6 @@ book-content {
.search-split > .primary:first-child { border-radius:var(--reader-radius) 0 0 var(--reader-radius); }
.search-split > .primary:only-child { border-radius:var(--reader-radius); }
.search-split .search-menu-toggle { border-radius:0 var(--reader-radius) var(--reader-radius) 0; padding-inline:.65rem; border-left:1px solid var(--reader-line); }
.search-menu-toggle .icon { transform:rotate(90deg); }
.search-menu { position:absolute; right:0; top:calc(100% + .45rem); z-index:5; width:min(17rem, calc(100vw - 4rem)); padding:.35rem; border:1px solid var(--reader-line); border-radius:var(--radius-md); background:var(--reader-paper); box-shadow:0 8px 24px #0002; }
.search-menu button { display:flex; align-items:center; width:100%; padding:.65rem .75rem; text-align:left; border:0; background:transparent; color:var(--reader-ink); border-radius:var(--radius-sm); }
.search-menu button:focus-visible { background:color-mix(in srgb, var(--reader-accent) 12%, transparent); }
@media (hover: hover) and (pointer: fine) { .search-menu button:hover { background:color-mix(in srgb, var(--reader-accent) 12%, transparent); }}
.search-menu small { display:block; margin-top:.2rem; color:var(--reader-muted); font-size:var(--text-meta); }
.catalog-summary { display:flex; flex-wrap:wrap; gap:.5rem 1rem; justify-content:space-between; align-items:center; }
.catalog-summary strong { font-size:var(--text-title); }
.catalog-summary span { display:flex; align-items:center; gap:.4rem; color:var(--reader-muted); font-size:var(--text-ui); }
@@ -3819,7 +3814,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.sources-hub .sources-tabs { flex: none; flex-wrap: nowrap; gap: 0; padding: 0 1rem; width: 100%; max-width: 66rem; margin: 0 auto 1rem; }
.sources-hub .sources-tabs [role=tab] { flex: 1; min-width: 0; min-height: 52px; padding: .85rem .35rem; border: 0; border-bottom: 2px solid var(--reader-line); background: transparent; color: var(--reader-muted); font: inherit; cursor: pointer; }
.sources-hub .sources-tabs [aria-selected=true] { color: var(--reader-ink); border-bottom-color: var(--reader-accent); font-weight: 650; }
.sources-hub .sources-tabs [role=tab]:focus-visible, .source-manage-trigger:focus-visible, .source-management-link:focus-visible { outline: var(--focus-ring); outline-offset: -2px; }
.sources-hub .sources-tabs [role=tab]:focus-visible { outline: var(--focus-ring); outline-offset: -2px; }
.sources-tab-dot { display: inline-block; width: 6px; height: 6px; margin: 0 0 .2rem .35rem; border-radius: 50%; background: var(--reader-fill); }
.sources-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .5rem; }
.sources-list-heading h2 { margin: 0; }
@@ -3827,20 +3822,16 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.source-add { display: inline-flex; align-items: center; gap: .3rem; min-height: 44px; padding: .5rem .7rem; }
.sources-list .source-entry { display: block; padding: 1rem 0; }
.sources-list .source-entry:last-child { padding-bottom: 0; }
.source-entry-top { display: flex; align-items: center; gap: 1rem; }
.source-entry-info { flex: 1; min-width: 0; }
/* Info left, actions right at every width; the actions wrap below only when the row is too narrow. */
.source-entry-top { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem; }
.source-entry-info { flex: 1 1 12rem; min-width: 0; }
.source-entry-info strong { display: block; line-height: 1.5; }
.source-entry-info small { font-size: var(--text-label); color: var(--reader-muted); opacity: 1; }
.source-state { display: inline-block; font-size: var(--text-meta); color: var(--reader-muted); margin-top: .5rem; }
.source-state::before { content: ''; display: inline-block; height: 5px; width: 5px; border-radius: 50%; background: currentColor; vertical-align: middle; margin-right: .35rem; }
.source-state.is-enabled { color: var(--reader-accent); }
.source-entry-actions { display: flex; align-items: center; gap: .6rem; flex-shrink: 0; }
.source-manage-trigger { display: inline-flex; align-items: center; justify-content: center; gap: .25rem; min-height: 44px; padding: .4rem; border: 0; background: transparent; font: inherit; font-size: var(--text-ui); color: var(--reader-muted); cursor: pointer; }
.source-manage-trigger[aria-expanded=true] .icon { transform: rotate(90deg); }
.source-management { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem 1rem; margin-top: .85rem; padding: .5rem .75rem; border-radius: var(--radius-md); background: color-mix(in srgb, var(--reader-ink) 4%, transparent); }
.source-management-link { display: inline-flex; align-items: center; justify-content: space-between; gap: .5rem; min-height: 44px; padding: .5rem 0; border: 0; background: transparent; color: var(--reader-ink); font: inherit; font-size: var(--text-ui); text-decoration: none; cursor: pointer; }
.source-management-link:disabled, .source-manage-trigger:disabled, .sources-hub [role=tab]:disabled { opacity: .5; cursor: default; }
.source-toggle { color: var(--reader-muted); }
.source-entry-actions { display: flex; align-items: center; gap: .6rem; flex-shrink: 0; margin-inline-start: auto; }
.sources-hub [role=tab]:disabled { opacity: .5; cursor: default; }
.sources-hub .sources-tabs [role=tab] { font-size: var(--text-ui); }
.source-picker label { max-width: 32rem; }
.sources-search .searchable-select { flex: 1 1 12rem; min-width: 12rem; }
@@ -3971,19 +3962,12 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
@media (max-width: 600px) {
.sources-header { gap:.6rem; padding-inline:calc(.75rem + var(--safe-left)) calc(.75rem + var(--safe-right)); }
.sources-header h1 { font-size:var(--text-title); }
.source-entry-top { align-items:flex-start; flex-direction:column; gap:.6rem; }
.source-entry-actions, .sources-actions { width:100%; flex-wrap:wrap; }
.source-entry-actions .button, .sources-actions .button { flex:1 1 8rem; }
.sources-actions { width:100%; flex-wrap:wrap; }
.sources-actions .button { flex:1 1 8rem; }
.source-interval { width:100%; justify-content:space-between; }
.source-interval select { flex:1; }
.library-files-screen .manager-actions { overflow-x:auto; }
}
@media (max-width: 600px) {
.source-entry-actions { flex-direction: column; gap: 0; }
.source-entry-top { align-items: center; }
.source-management-link { width: 100%; }
.source-management-link + .source-management-link { border-top: 1px solid var(--reader-line); }
}
/* Reading source actions share the toolbar; provenance occupies its own compact row. */
.reader-screen .topbar { flex-wrap: wrap; }
@@ -4124,11 +4108,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.source-capabilities dl > div { padding: 8px; border: 1px solid currentColor; border-radius: var(--radius-sm); }
.source-capabilities dd { margin: 4px 0 0; }
/* Align split search actions and source filtering controls. */
.search-menu button { display:grid; grid-template-columns:2rem minmax(0,1fr); align-items:center; column-gap:.65rem; }
.search-menu button > .icon { width:2rem; height:2rem; display:grid; place-items:center; }
.search-menu button > span { min-width:0; line-height:1.35; }
.search-menu small { line-height:1.4; }
/* The class is on the sheet's <input> itself; a 16px font keeps iOS from zooming on focus. */
.catalog-source-search { display:block; flex:none; width:100%; min-height:44px; box-sizing:border-box; padding:.6rem .8rem; border:1px solid var(--reader-line); border-radius:var(--radius-md); background:var(--reader-paper); color:var(--reader-ink); font:inherit; font-size:var(--text-title); }
.catalog-source-search:focus { border-color:var(--reader-accent); outline:2px solid color-mix(in srgb,var(--reader-accent) 25%,transparent); outline-offset:1px; }
+7
View File
@@ -211,6 +211,13 @@
min-width: 0;
}
.sheet-list-description {
display: block;
color: var(--color-muted);
font-size: var(--text-meta);
line-height: 1.4;
}
.sheet-list-item[aria-checked='true'] {
color: var(--reader-accent);
font-weight: 600;
+7 -1
View File
@@ -33,6 +33,8 @@ export interface MenuAction {
danger?: boolean;
/** A choice in a single-select menu (a filter, a sort order): marked when current. */
checked?: boolean;
/** A second, muted line under the label, for actions whose names alone are close. */
description?: string;
}
/** `false`, `null` and `undefined` entries are skipped, so callers can write `admin && {…}`. */
@@ -156,15 +158,19 @@ function ActionDropdown({ id, label, anchor, actions, onClose, onPick }: {
/** One row; `base` names its classes (`sheet-list-item`, `sheet-list-label`, …). */
function MenuRow({ action, base, onPick }: { action: MenuAction; base: 'action-menu' | 'sheet-list'; onPick(): void }): JSX.Element {
const className = `${base}-item`;
const descriptionId = useId();
return (
<button type="button"
role={action.checked === undefined ? 'menuitem' : 'menuitemradio'}
aria-checked={action.checked}
// The name stays the label alone; the second line is a description.
aria-label={action.description ? action.label : undefined}
aria-describedby={action.description ? descriptionId : undefined}
className={action.danger ? `${className} is-danger` : className}
disabled={action.disabled ?? false}
onClick={onPick}>
{action.icon ? <span className={`${base}-icon`} aria-hidden="true">{action.icon}</span> : null}
<span className={`${base}-label`}>{action.label}</span>
<span className={`${base}-label`}>{action.label}{action.description ? <small id={descriptionId} className={`${base}-description`}>{action.description}</small> : null}</span>
{action.checked ? <Icon name="check" className={`${base}-check`} /> : null}
</button>
);
+2 -2
View File
@@ -41,8 +41,8 @@ try {
if(json!==undefined)return route.fulfill({contentType:'application/json',body:JSON.stringify(json)});
return route.continue();
});
await page.goto(base); await page.locator('input[autocomplete="username"]').fill('review');await page.locator('input[type="password"]').fill('password12');await page.locator('button[type=submit]').click();await page.locator('.shelf-screen').waitFor();
await page.goto(base+'/#/sources');await page.getByRole('tab',{name:'书源管理',exact:true}).click();await page.getByRole('button',{name:'管理',exact:true}).click();await page.getByRole('link',{name:'来源配置'}).click();await page.getByRole('heading',{name:'我的远程书源',exact:true}).waitFor();
await page.goto(base); await page.locator('input[autocomplete="username"]').fill('review');await page.locator('input[type="password"]').fill('password12');await page.locator('button[type=submit]').click();await page.locator('.home-page').waitFor();
await page.goto(base+'/#/sources');await page.getByRole('tab',{name:'书源管理',exact:true}).click();await page.getByRole('button',{name:'管理',exact:true}).click();await page.getByRole('menuitem',{name:'来源配置'}).click();await page.getByRole('heading',{name:'我的远程书源',exact:true}).waitFor();
assert.equal(new URL(page.url()).hash,'#/sources/catalog/library');
assert.equal(await page.getByRole('tab').count(),2);
await page.getByLabel('配置名称').fill('修改后的配置');
+1 -1
View File
@@ -124,7 +124,7 @@ try {
await tab('书源管理').click();
const row = page.locator('.sources-row').filter({hasText:'示例章节源'});
await row.getByRole('button',{name:'管理',exact:true}).click();
await row.getByRole('button', { name: '设为默认', exact: true }).click(); await idle();
await page.getByRole('menuitem', { name: '设为默认', exact: true }).click(); await idle();
await row.locator('.source-default-badge').waitFor();
await shot('hub-management-mobile');
for (const width of [320,390,1280]) { await page.setViewportSize({width,height:844}); await shot('hub-'+width); }