feat(web): one themed drop-down everywhere instead of native selects

- MediaSelect moves to ui/select.tsx as Select (old name kept as an alias);
  its colours fall back to the theme tokens outside 影音
- sources, reader panels and admin pages use it; business settings default
  to it
- a test rejects a native <select> anywhere but the two select components
- review scripts target the hidden native element

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude committed 2026-11-11 06:01:07 +00:00
1 parent eff234749d
commit 5eb38a974b
20 files changed
+201 -189

No files matched your search

+1 -1
View File
@@ -873,7 +873,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 二级页面的返回键只有一种几何:44px 的点击区距屏幕边 4px(侧栏模式下距内容左缘 −8px)、22px 的箭头、标题在其后 4px。影音页框(`.media-back-button`)是基准,书库、文件管理、书源的页头按各自的内边距换算到同一位置(`reader.css` 末尾),`ui:review` 逐页比对。
- 手机上书架的“书库”“书源”两个入口在标题行右侧、设置按钮之前(纯文字按钮);701px 以上仍在搜索框左侧。单独占一行时第一张封面要到屏幕三分之一处才出现,`ui:review` 断言 390px 下第一张封面的顶边不超过视口高度的三分之一。
- 列表记住滚动位置:从书架、书库打开一本书再返回(浏览器 Back 或页面自己的返回),回到打开它的那张封面,而不是页首。位置按账号和 URL 记在内存里(`web/src/shared/ui/use-scroll-memory.ts`,影音的搜索与播放记录用的是同一个);在页面里主动换页、换排序、换筛选或进文件夹是一张新列表,不恢复旧位置。
- 原生下拉框只有一种箭头:`currentColor` 画的小三角(`reader.css` 末尾),所以跟随每一套主题和纸色。控件本身仍是原生 `<select>`(手机上系统的选择器比自绘的好用);影音里已经自绘的 `MediaSelect`、`SearchableSelect` 不受影响。
- 下拉框只有一种:`Select`(`web/src/ui/select.tsx`,样式 `styles/select.css`),用法与 `<select>` 相同(同样的属性、`<option>` 子节点、原生 `change` 事件)。原生 `<select>` 仍在 DOM 里但不可见,值、表单校验和按 `select[aria-label=…]` 取值的测试都还落在它上面;看得见的是一个 `role="combobox"` 的按钮和浮在顶层的选项列表(在 Sheet 里也能用,Esc 只收起列表)。界面里不再直接写 `<select>`;带搜索的长列表用 `SearchableSelect`。它起源于影音的 `MediaSelect`,那个名字保留为别名。
- 书架:搜索框下是“全部 / 在读 / 想读 / 已读”和“分组”(按书的系列或标签筛选),都写进 URL(`?status=`、`?series=`、`?tag=`)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。
- 视频播放:字幕、音轨、版本是同一个“字幕与音轨”抽屉的三个分页。字幕页列出关闭、外挂(服务器上的 srt/ass/vtt)、内嵌,可“添加外挂字幕”(本机文件在浏览器里转换为 WebVTT,仅本次播放)、以 0.5 秒调整字幕延迟(外挂与本机字幕),并选择字号(小/标准/大,按设备保存)。工具栏的“全屏”进入横屏全屏:选集、倍速、适应屏幕(铺满裁边)、字幕和锁定浮在画面上,随 DPlayer 控制条一起淡出;锁定后点击不再作用于播放器,只留解锁按钮。这些只在网页播放器提供,Android 播放器用自己的菜单。
- 媒体库管理:列表上方是媒体库数量和两个常驻操作“扫描与刮削”“新建媒体库”(没有媒体库时由空状态给出“新建媒体库”)。每个媒体库卡片用频道色图标区分类型,受限库标“指定用户”,⋯ 菜单是编辑、访问权限、扫描记录;底部是缺失文件数(有缺失时用警示色)和最近一次扫描——完成时间和“重新扫描”(从未扫描时为“开始扫描”)、进行中的进度(“已处理 62%”)和“取消”、失败原因和“重试”“查看详情”。右侧“扫描与元数据”是服务器业务配置里的四个开关,逐项保存;旧服务器没有这些开关时卡片占满宽度。样式在 `web/src/features/media/styles/manager.css`。
+2 -115
View File
@@ -1,115 +1,2 @@
import { Icon } from '../../../ui/icon.tsx';
import type { SelectHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import '../styles/select.css';
type Choice={value:string;label:string;disabled:boolean;group:string};
type Props=Omit<SelectHTMLAttributes<HTMLSelectElement>,'ref'|'multiple'|'size'> & {
variant?:'field'|'plain';
controlRef?:{current:HTMLButtonElement|null};
};
let nextId=0;
/** Native form semantics with one themed, keyboard-accessible visual control. */
export function MediaSelect({variant='field',controlRef,...props}:Props){
const native=useRef<HTMLSelectElement>(null),trigger=useRef<HTMLButtonElement>(null),panel=useRef<HTMLDivElement>(null),host=useRef<HTMLSpanElement>(null);
const id=useRef('media-select-'+ ++nextId).current;
const [choices,setChoices]=useState<Choice[]>([]),[selected,setSelected]=useState(''),[open,setOpen]=useState(false),[active,setActive]=useState(-1),[label,setLabel]=useState(''),[validation,setValidation]=useState('');
const search=useRef({text:'',at:0});
const defaultApplied=useRef(false);
const {children,className,id:fieldId,onChange,onFocus,onInvalid,onClick,...attributes}=props;
const supportsPopover=typeof HTMLElement!=='undefined'&&'showPopover' in HTMLElement.prototype;
function sync(){
const select=native.current;if(!select)return;
const values=Array.from(select.options).filter(option=>!option.hidden).map(option=>({value:option.value,label:option.label,disabled:option.disabled||Boolean(option.closest('optgroup')?.disabled),group:option.closest('optgroup')?.label||''}));
setChoices(current=>JSON.stringify(current)===JSON.stringify(values)?current:values);setSelected(select.value);
if(!props['aria-label']&&!props['aria-labelledby']){
const enclosing=host.current?.closest('label')?.cloneNode(true) as HTMLLabelElement|undefined;
enclosing?.querySelector('.media-select-control')?.remove();setLabel(enclosing?.textContent?.trim()||String(props.name||'选择选项'));
}
}
useLayoutEffect(()=>{
const select=native.current;
if(select&&!defaultApplied.current&&props.value===undefined&&props.defaultValue!==undefined&&select.options.length){
for(const option of select.options)option.defaultSelected=option.value===String(props.defaultValue);
select.value=String(props.defaultValue);defaultApplied.current=true;
}
sync();
},[children,props.value,props.defaultValue]);
useLayoutEffect(()=>{if(controlRef)controlRef.current=trigger.current;return()=>{if(controlRef)controlRef.current=null;};},[controlRef]);
useLayoutEffect(()=>{if(props.disabled)setOpen(false);},[props.disabled]);
function position(){
const button=trigger.current,menu=panel.current;if(!button||!menu)return;
const rect=button.getBoundingClientRect(),viewport=window.visualViewport;
const left=viewport?.offsetLeft||0,top=viewport?.offsetTop||0,width=viewport?.width||window.innerWidth,height=viewport?.height||window.innerHeight;
const gap=6,edge=8,menuWidth=Math.min(Math.max(rect.width,192),width-edge*2);
const below=top+height-rect.bottom-gap-edge,above=rect.top-top-gap-edge;
const upwards=below<180&&above>below,space=Math.max(44,upwards?above:below);
menu.style.width=menuWidth+'px';menu.style.maxHeight=Math.min(320,space)+'px';
menu.style.left=Math.max(left+edge,Math.min(rect.left,left+width-menuWidth-edge))+'px';
menu.style.top=(upwards?Math.max(top+edge,rect.top-gap-Math.min(menu.scrollHeight,320,space)):rect.bottom+gap)+'px';
}
useLayoutEffect(()=>{
if(!open)return;
const menu=panel.current;if(!menu)return;
if(supportsPopover)menu.showPopover();position();
const outside=(event:Event)=>{if(!host.current?.contains(event.target as Node))setOpen(false);};
const close=()=>setOpen(false);
document.addEventListener('pointerdown',outside,true);document.addEventListener('focusin',outside,true);
window.addEventListener('resize',position);window.addEventListener('scroll',position,true);
window.visualViewport?.addEventListener('resize',position);window.visualViewport?.addEventListener('scroll',position);
native.current?.form?.addEventListener('reset',close);
return()=>{
if(supportsPopover&&menu.matches(':popover-open'))menu.hidePopover();
document.removeEventListener('pointerdown',outside,true);document.removeEventListener('focusin',outside,true);
window.removeEventListener('resize',position);window.removeEventListener('scroll',position,true);
window.visualViewport?.removeEventListener('resize',position);window.visualViewport?.removeEventListener('scroll',position);
native.current?.form?.removeEventListener('reset',close);
};
},[open,choices]);
useLayoutEffect(()=>{if(open&&active>=0)panel.current?.querySelector<HTMLElement>('[data-index="'+active+'"]')?.scrollIntoView?.({block:'nearest'});},[open,active]);
useLayoutEffect(()=>{
const form=native.current?.form;if(!form)return;
const reset=()=>{setValidation('');queueMicrotask(sync);};form.addEventListener('reset',reset);return()=>form.removeEventListener('reset',reset);
},[]);
function show(){
if(props.disabled)return;
const current=choices.findIndex(choice=>choice.value===selected&&!choice.disabled);
setActive(current>=0?current:choices.findIndex(choice=>!choice.disabled));setOpen(true);search.current={text:'',at:0};
}
function choose(index:number){
const choice=choices[index],select=native.current;if(!choice||choice.disabled||!select)return;
if(select.value!==choice.value){select.value=choice.value;select.dispatchEvent(new Event('change',{bubbles:true}));}
setOpen(false);trigger.current?.focus();
}
function keyboard(event:ReactKeyboardEvent<HTMLButtonElement>){
if(event.key==='Escape'&&open){event.preventDefault();event.stopPropagation();setOpen(false);return;}
if(event.key==='Tab'){setOpen(false);return;}
if(['ArrowDown','ArrowUp','Home','End'].includes(event.key)){
event.preventDefault();if(!open){show();if(event.key!=='Home'&&event.key!=='End')return;}
const enabled=choices.map((choice,index)=>choice.disabled?-1:index).filter(index=>index>=0);
const offset=enabled.indexOf(active),step=event.key==='ArrowUp'?-1:1;
setActive(event.key==='Home'?enabled[0]??-1:event.key==='End'?enabled.at(-1)??-1:enabled[Math.max(0,Math.min(enabled.length-1,offset+step))]??-1);return;
}
if(event.key==='Enter'||event.key===' '){event.preventDefault();if(open)choose(active);else show();return;}
if(event.key.length===1&&!event.ctrlKey&&!event.metaKey&&!event.altKey){
event.preventDefault();if(!open)show();
const at=Date.now(),previous=at-search.current.at<600?search.current.text:'';
const text=(previous+event.key).toLocaleLowerCase();search.current={text,at};
const match=choices.findIndex(choice=>!choice.disabled&&choice.label.toLocaleLowerCase().startsWith(text));if(match>=0)setActive(match);
}
}
return <span ref={host} className={'media-select-control '+(className||'')} data-variant={variant} data-open={open||undefined}>
<select {...attributes} id={fieldId?String(fieldId)+'-native':undefined} className="media-select-native" ref={native} aria-hidden="true" tabIndex={-1} onClick={event=>{event.preventDefault();trigger.current?.focus();show();onClick?.(event);}} onFocus={event=>{trigger.current?.focus();onFocus?.(event);}} onInvalid={event=>{event.preventDefault();setValidation(event.currentTarget.validationMessage||'请选择一项');trigger.current?.focus();onInvalid?.(event);}} onChange={event=>{setValidation('');sync();onChange?.(event);}}>{children}</select>
<button ref={trigger} id={fieldId} type="button" className="media-select-trigger" role="combobox" aria-label={props['aria-label']||label||undefined} aria-labelledby={props['aria-labelledby']} aria-describedby={[props['aria-describedby'],validation?id+'-error':''].filter(Boolean).join(' ')||undefined} aria-invalid={props['aria-invalid']||(validation?true:undefined)} aria-required={props.required?true:undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={id} aria-activedescendant={open&&active>=0?id+'-'+active:undefined} disabled={props.disabled} title={props.title} onClick={()=>open?setOpen(false):show()} onKeyDown={keyboard}>
<span>{choices.find(choice=>choice.value===selected)?.label||'请选择'}</span><Icon name="chevron-down" size={16} />
</button>
{validation&&<span id={id+'-error'} className="media-select-error" role="alert">{validation}</span>}
{open&&<div ref={panel} id={id} className="media-select-menu" role="listbox" aria-label={props['aria-label']||label||undefined} aria-labelledby={props['aria-labelledby']} {...(supportsPopover?{popover:'manual' as const}:{})} onPointerDown={event=>event.preventDefault()}>
{choices.map((choice,index)=><div key={`${choice.group}\u0000${choice.value}\u0000${choice.label}`} id={id+'-'+index} data-index={index} className="media-select-option" role="option" aria-selected={choice.value===selected} aria-disabled={choice.disabled||undefined} data-active={index===active||undefined} onPointerMove={()=>!choice.disabled&&setActive(index)} onClick={event=>{event.preventDefault();event.stopPropagation();choose(index);}}><span>{choice.group&&<small>{choice.group}</small>}{choice.label}</span>{choice.value===selected&&<Icon name="check" size={16} />}</div>)}
</div>}
</span>;
}
/** Lives in ui/ now (every screen uses it); kept here so media imports stay put. */
export { MediaSelect } from '../../../ui/select.tsx';
@@ -1,3 +1,4 @@
import { Select } from '../../../ui/select.tsx';
import { notify } from '../../../ui/notifications.ts';
import { Button, Callout } from '../../../ui/toolkit.tsx';
import { SourcesHeader } from '../components/SourcesHeader.tsx';
@@ -58,14 +59,14 @@ export function SourceCatalogPage() {
finally { setBusy(false); }
};
if (sources.isPending) return <div className="sources-screen"><FloatingNotice message="正在加载书源…" busy /><main className="sources-body" /></div>;
if (!source) return <div className="sources-screen source-catalog-screen"><SourcesHeader title="选择书源" /><main className="sources-body"><section className="sources-card source-picker"><h2>选择来源</h2><p className="muted">选择一个已启用的书源开始搜索或浏览</p><label>书源<select defaultValue="" onChange={event => { const value = event.currentTarget.value; if (value) navigate(`/sources/${encodeURIComponent(value)}/browse`); }}><option value="" disabled>选择来源</option>{sources.data?.filter(item => item.enabled && item.descriptor).map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></section></main></div>;
if (!source) return <div className="sources-screen source-catalog-screen"><SourcesHeader title="选择书源" /><main className="sources-body"><section className="sources-card source-picker"><h2>选择来源</h2><p className="muted">选择一个已启用的书源开始搜索或浏览</p><label>书源<Select defaultValue="" onChange={event => { const value = event.currentTarget.value; if (value) navigate(`/sources/${encodeURIComponent(value)}/browse`); }}><option value="" disabled>选择来源</option>{sources.data?.filter(item => item.enabled && item.descriptor).map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</Select></label></section></main></div>;
const supportsSearch = source.descriptor?.capabilities.includes('search');
const supportsBrowse = source.descriptor?.capabilities.includes('browse');
return <div className="sources-screen source-catalog-screen">
<SourcesHeader title={source.name} subtitle={source.descriptor?.label ?? '书源目录'} />
<main className="sources-body">
{error && <Callout tone="error" onDismiss={() => setError('')}>{error}</Callout>}
{supportsSearch && <section className="sources-card source-catalog"><form className="sources-search" onSubmit={event => { event.preventDefault(); void search(false); }}><label className="source-keyword">搜索书籍<input type="search" placeholder="输入书名或作者" value={query} onChange={event => setQuery(event.currentTarget.value)} required /></label>{filterQuery.data?.map(field => field.type === 'select' ? <label key={field.key}>{field.label}<select value={filters[field.key] ?? ''} onChange={event => { const value = event.currentTarget.value; setFilters(previous => ({ ...previous, [field.key]: value })); }}><option value="">不限</option>{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label> : null)}<Button type="submit" className="primary" disabled={busy}>搜索</Button>{page?.nextCursor && <Button type="button" disabled={busy} onClick={() => void search(true)}>继续搜索</Button>}</form></section>}
{supportsSearch && <section className="sources-card source-catalog"><form className="sources-search" onSubmit={event => { event.preventDefault(); void search(false); }}><label className="source-keyword">搜索书籍<input type="search" placeholder="输入书名或作者" value={query} onChange={event => setQuery(event.currentTarget.value)} required /></label>{filterQuery.data?.map(field => field.type === 'select' ? <label key={field.key}>{field.label}<Select value={filters[field.key] ?? ''} onChange={event => { const value = event.currentTarget.value; setFilters(previous => ({ ...previous, [field.key]: value })); }}><option value="">不限</option>{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</Select></label> : null)}<Button type="submit" className="primary" disabled={busy}>搜索</Button>{page?.nextCursor && <Button type="button" disabled={busy} onClick={() => void search(true)}>继续搜索</Button>}</form></section>}
<div className="source-catalog-actions">{supportsBrowse && <Button disabled={busy} onClick={() => void browse()}>浏览目录首页</Button>}</div>
{page?.batch && <p className="muted">已检查 {page.batch.completed} / {page.batch.total} 个来源</p>}
{page?.navigation && <nav className="source-navigation sources-actions" aria-label="目录">{page.navigation.map(item => <Button key={item.ref} onClick={() => void browse(item.ref)}>{item.title}</Button>)}</nav>}
@@ -1,3 +1,4 @@
import { Select } from '../../../ui/select.tsx';
import { useEffect, useState, type FormEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { SourceType } from '../../../api/sources.ts';
@@ -50,17 +51,17 @@ export function SourceEditorPage() {
<main className="sources-body">
{sourceId && !source ? <Callout tone="error">来源不存在</Callout> : <form className="sources-card" onSubmit={save}>
<label>来源类型
<select required disabled={!!sourceId} value={typeKey} onChange={event => { setTypeKey(event.currentTarget.value); setConfig({}); setRaw('{}'); }}>
<Select required disabled={!!sourceId} value={typeKey} onChange={event => { setTypeKey(event.currentTarget.value); setConfig({}); setRaw('{}'); }}>
<option value="" disabled>选择来源类型</option>
{types.data?.map(item => <option key={`${item.pluginId}/${item.id}`} value={`${item.pluginId}/${item.id}`}>{item.label} ({item.pluginId})</option>)}
</select>
</Select>
</label>
<label>名称<input required value={name} onChange={event => setName(event.currentTarget.value)} /></label>
{type?.configSchema?.properties
? Object.entries(type.configSchema.properties).map(([key, field]) => field.type === 'boolean'
? <label key={key} className="sources-consent"><input type="checkbox" checked={config[key] === true} onChange={event => setField(key, event.currentTarget.checked)} />{field.title ?? key}</label>
: field.enum
? <label key={key}>{field.title ?? key}<select value={String(config[key] ?? field.default ?? '')} onChange={event => setField(key, event.currentTarget.value)}>{field.enum.map((value, index) => <option key={value} value={value}>{field.enumNames?.[index] ?? value}</option>)}</select></label>
? <label key={key}>{field.title ?? key}<Select value={String(config[key] ?? field.default ?? '')} onChange={event => setField(key, event.currentTarget.value)}>{field.enum.map((value, index) => <option key={value} value={value}>{field.enumNames?.[index] ?? value}</option>)}</Select></label>
: <label key={key}>{field.title ?? key}<input type={field.type === 'number' || field.type === 'integer' ? 'number' : 'text'} value={String(config[key] ?? field.default ?? '')} onChange={event => setField(key, field.type === 'number' || field.type === 'integer' ? Number(event.currentTarget.value) : event.currentTarget.value)} /></label>)
: <label>配置 JSON<textarea rows={8} value={raw} onChange={event => setRaw(event.currentTarget.value)} /></label>}
{jsonError && <Callout tone="error">{jsonError}</Callout>}
@@ -1,3 +1,4 @@
import { Select } from '../../../ui/select.tsx';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
@@ -47,9 +48,9 @@ function FieldControl({ field, value, disabled, onChange, onSelectAction }: {
if (field.type === 'textarea') return <textarea {...common} placeholder={field.placeholder} value={String(value ?? '')} onChange={event => onChange(event.currentTarget.value)} />;
if (field.type === 'file') return <input {...common} type="file" onChange={event => { const file = event.currentTarget.files?.[0]; if (file) void fileToValue(file).then(onChange); }} />;
if (field.type === 'boolean') return <input {...common} type="checkbox" checked={value === true} onChange={event => onChange(event.currentTarget.checked)} />;
if (field.type === 'select') return <select {...common} value={String(value ?? '')} onChange={event => { const next = event.currentTarget.value; onChange(next); if (field.changeAction) onSelectAction?.(field, next); }}>
if (field.type === 'select') return <Select {...common} value={String(value ?? '')} onChange={event => { const next = event.currentTarget.value; onChange(next); if (field.changeAction) onSelectAction?.(field, next); }}>
{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>;
</Select>;
return <input {...common} type={field.type === 'password' ? 'password' : field.type === 'number' ? 'number' : 'text'} autoComplete={field.type === 'password' ? 'off' : undefined} placeholder={field.placeholder} min={field.min} max={field.max} value={value === undefined || value === null ? '' : String(value)} onChange={event => {
const raw = event.currentTarget.value;
onChange(field.type === 'number' ? (raw === '' ? '' : Number(raw)) : raw);
@@ -1,3 +1,4 @@
import { Select } from '../../../ui/select.tsx';
import { describeError } from '../../../api/errors.ts';
import { Icon } from '../../../ui/icon.tsx';
import { Sheet } from '../../../ui/sheet.tsx';
@@ -169,7 +170,7 @@ export function SourcesPage() {
{ 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.error && <Callout tone="error" title="追更列表加载失败" action={{ label: '重试', onClick: () => void subscriptions.refetch() }}>{describeError(subscriptions.error)}</Callout>}{!subscriptions.isPending && !subscriptions.error && !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'] })).catch(reason => notify(reason instanceof Error ? reason.message : '修改检查间隔失败,请重试', { kind: 'error' })).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 === 'updates' && <section className="sources-card"><h2>自动追更</h2><p className="notice">服务端定时检查目录,浏览器关闭后继续运行。正文和图片在阅读时缓存。</p>{subscriptions.isPending && <FloatingNotice message="正在加载追更列表…" busy />}{subscriptions.error && <Callout tone="error" title="追更列表加载失败" action={{ label: '重试', onClick: () => void subscriptions.refetch() }}>{describeError(subscriptions.error)}</Callout>}{!subscriptions.isPending && !subscriptions.error && !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'] })).catch(reason => notify(reason instanceof Error ? reason.message : '修改检查间隔失败,请重试', { kind: 'error' })).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>)}
</main>
{activeGroup && <Sheet title="书源列表" subtitle={`${activeGroup.entries.length} 条书源`} onClose={() => { setResultGroupKey(null); setSourceNameQuery(''); }}>
+4 -33
View File
@@ -3658,7 +3658,9 @@ book-content {
.sources-row > div:first-child { flex:1 1 15rem; min-width:0; overflow-wrap:anywhere; }
.sources-row small { display:block; margin-top:.35rem; opacity:.75; }
.source-interval { display:inline-flex !important; flex-direction:row !important; align-items:center; gap:.45rem !important; margin:0 !important; color:var(--reader-muted); font-size:var(--text-label); }
.source-interval select { min-height:40px !important; width:auto !important; padding:.45rem 1.9rem .45rem .55rem !important; }
/* The drop-down is ui/select.tsx; in this row it is as wide as its value, not the row. */
.source-interval .media-select-control { width:auto; min-width:7.5rem; }
.source-interval .media-select-trigger { min-height:40px; padding:.45rem .55rem; }
.source-catalog-actions { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem; margin:.5rem 0 1rem; }
.source-navigation { flex-wrap:wrap; margin:.5rem 0 1rem; }
.library-files-screen .manager-status { padding:.65rem 1rem 1rem; }
@@ -4017,7 +4019,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.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; }
.source-interval .media-select-control { flex:1; }
.library-files-screen .manager-actions { overflow-x:auto; }
}
@@ -4219,34 +4221,3 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.sources-header > .icon-button[aria-label='返回'] > .icon {
font-size: var(--text-display);
}
/*
* One arrow for every native select.
*
* Half of them were `appearance: none` with nothing drawn in its place — a select
* that looks exactly like a text field — and the other half showed the platform's
* own arrow, which is a different glyph per browser and ignores the theme. This is
* the one indicator: a small triangle painted in `currentColor`, so it follows
* every theme, the reading papers included. The control stays a native select (the
* OS picker on a phone is better than anything drawn here); the two selects that
* sit hidden behind a custom control are left alone.
*
* The weight of the selector is deliberate: the rules it has to outlast set
* `background` as a shorthand, which would otherwise erase the image.
*/
select:not(.media-select-native):not(.searchable-select-native):not([multiple]) {
appearance: none;
-webkit-appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-size: 5px 5px, 5px 5px;
background-position: calc(100% - 0.95rem) 50%, calc(100% - 0.95rem + 5px) 50%;
background-repeat: no-repeat;
padding-inline-end: 1.9rem;
text-overflow: ellipsis;
}
[dir='rtl'] select:not(.media-select-native):not(.searchable-select-native):not([multiple]) {
background-position: 0.95rem 50%, calc(0.95rem - 5px) 50%;
}
@@ -1,29 +1,31 @@
/* The colour names are 影音's aliases (presentation.css); each falls back to the theme token it
stands for, so the control looks the same on screens that never define the aliases. */
.media-select-control {position:relative;display:inline-flex;flex-direction:column;vertical-align:middle;min-width:0;max-width:100%;width:100%}
.media-select-control .media-select-native {position:absolute!important;width:1px!important;height:1px!important;min-height:0!important;padding:0!important;border:0!important;margin:-1px!important;clip-path:inset(50%)!important;overflow:hidden!important;white-space:nowrap!important;pointer-events:none!important}
.media-select-control .media-select-trigger {display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;min-height:44px;width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--media-paper);color:var(--text);text-align:left;font:inherit;cursor:pointer}
.media-select-control .media-select-trigger {display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;min-height:44px;width:100%;padding:10px 12px;border:1px solid var(--border, var(--color-line));border-radius:var(--radius-sm);background:var(--media-paper, var(--color-surface));color:var(--text, var(--color-ink));text-align:left;font:inherit;cursor:pointer}
.media-select-trigger>span {overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.media-select-trigger>svg {flex-shrink:0;color:var(--muted);transition:transform var(--motion-duration) var(--motion-ease)}
.media-select-trigger>svg {flex-shrink:0;color:var(--muted, var(--color-muted));transition:transform var(--motion-duration) var(--motion-ease)}
.media-screen .media-library-filter .media-select-trigger>svg {position:static;transform:none;pointer-events:none}
.media-screen .media-library-filter .media-select-trigger[aria-expanded=true]>svg {transform:rotate(180deg)}
.media-select-trigger[aria-expanded=true]>svg {transform:rotate(180deg)}
.media-select-control .media-select-trigger:focus-visible {outline:var(--focus-ring);outline-offset:var(--focus-offset)}
.media-select-control .media-select-trigger:disabled {opacity:.45;cursor:default}
.media-select-error {display:block;margin-top:5px;font-size:var(--text-meta);color:var(--media-error-ink)}
.media-select-error {display:block;margin-top:5px;font-size:var(--text-meta);color:var(--media-error-ink, var(--color-danger))}
.media-select-control[data-variant=plain] {width:auto}
.media-select-control[data-variant=plain]>.media-select-trigger {border:0;background:transparent;padding:0 2px;font-size:var(--text-meta);gap:8px}
.media-select-menu {position:fixed;inset:auto;box-sizing:border-box;margin:0;padding:5px;min-width:0;max-width:calc(100vw - 16px);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--border) transparent;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--media-paper);color:var(--text);box-shadow:0 8px 30px #00000024;z-index:1000;font:var(--text-ui)/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color-scheme:inherit}
.media-select-menu {position:fixed;inset:auto;box-sizing:border-box;margin:0;padding:5px;min-width:0;max-width:calc(100vw - 16px);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--border, var(--color-line)) transparent;border:1px solid var(--border, var(--color-line));border-radius:var(--radius-md);background:var(--media-paper, var(--color-surface));color:var(--text, var(--color-ink));box-shadow:0 8px 30px #00000024;z-index:1000;font:var(--text-ui)/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color-scheme:inherit}
.media-select-menu::backdrop {background:transparent;pointer-events:none}
.media-select-option {display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:44px;box-sizing:border-box;padding:10px 12px;border-radius:var(--radius-sm);cursor:pointer;overflow-wrap:anywhere}
.media-select-option>span {min-width:0}
.media-select-option>svg {flex-shrink:0;color:var(--media-accent)}
.media-select-option small {display:block;font-size:var(--text-meta);color:var(--muted)}
.media-select-option[data-active=true] {background:var(--surface)}
.media-select-option[aria-selected=true] {background:var(--media-selection);color:var(--media-accent);font-weight:550}
.media-select-option>svg {flex-shrink:0;color:var(--media-accent, var(--color-brand-text))}
.media-select-option small {display:block;font-size:var(--text-meta);color:var(--muted, var(--color-muted))}
.media-select-option[data-active=true] {background:var(--surface, var(--color-surface-2))}
.media-select-option[aria-selected=true] {background:var(--media-selection, var(--color-brand-soft));color:var(--media-accent, var(--color-brand-text));font-weight:550}
.media-select-option[aria-disabled=true] {opacity:.4;cursor:default}
.media-toolbar>.media-select-control,.media-edition-picker>.media-select-control {width:auto;max-width:65%}
.media-setting-field>.media-select-control {max-width:60%;width:auto}
.media-form .media-setting-field>.media-select-control {max-width:100%;width:100%}
.media-screen .media-playback-options .media-select-control {width:auto}
.media-screen .media-playback-options .media-select-trigger {min-height:24px;height:24px;padding:0 3px;border:0;background:transparent;font-size:var(--text-meta);gap:3px;color:var(--muted)}
.media-screen .media-playback-options .media-select-trigger {min-height:24px;height:24px;padding:0 3px;border:0;background:transparent;font-size:var(--text-meta);gap:3px;color:var(--muted, var(--color-muted))}
.media-screen .media-playback-options .media-select-trigger>svg {width:12px;height:12px}
@media(prefers-reduced-motion:reduce){.media-select-trigger>svg{transition:none}}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import {FloatingNotice} from './floating-notice.tsx';
import { Callout } from './toolkit.tsx';
import { Select as ThemedSelect } from './select.tsx';
import { ConfirmSheet } from './sheet.tsx';
import '../styles/business-settings.css';
import type { ChangeEvent, ReactNode } from 'react';
@@ -15,7 +16,7 @@ interface SettingsView {
fields:Array<{key:string;label:string;type:'text'|'textarea'|'password'|'number'|'checkbox'|'url'|'select';min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}>;
}
export function BusinessSettings({api,group,onSaved,selectControl,showMedia=true,modulesStatus}:{api:SettingsTransport;group?:string;onSaved?:()=>void;selectControl?:any;showMedia?:boolean;modulesStatus?:ReactNode}){
const Select=selectControl??'select';
const Select=selectControl??ThemedSelect;
const [groups,setGroups]=useState<SettingsView[]>([]),[selected,setSelected]=useState(group||'tts');
const [values,setValues]=useState<Record<string,string|number|boolean>>({}),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState(''),[retry,setRetry]=useState(0);
const [voices,setVoices]=useState<Array<{id:string;name:string}>>([]),[voice,setVoice]=useState(''),[audio,setAudio]=useState('');
+5 -4
View File
@@ -1,3 +1,4 @@
import { Select } from './select.tsx';
import type { CSSProperties } from 'react';
import { paperIds, readerPapers, themes, type AppThemeId, type ModePreference, type ReaderPaper, type ReaderSurface, type ResolvedTheme, type ThemeMode, type ThemePreference } from '../shared/theme/theme.ts';
import type { ChapterQuality } from '../api/sources.ts';
@@ -366,9 +367,9 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
: state.alternativeChecks[entry.ref]!.error}</span>}
</button>)}</div>
{!!state.alternatives.errors?.length && <CatalogFeedback page={state.alternatives} merged compact searching={!!state.alternativesSearching} />}
{state.alternativeChapters && <Sheet title="选择阅读章节" size="compact" busy={!!state.switching} onClose={() => handlers.onCancelAlternative?.()}><div className="alternative-preview"><h4>{state.alternativeTitle}</h4><label>切换后阅读的章节<select value={state.alternativeChapter ?? ''} disabled={state.switching === true} onChange={event => handlers.onAlternativeChapter?.(event.currentTarget.value)}>
{state.alternativeChapters && <Sheet title="选择阅读章节" size="compact" busy={!!state.switching} onClose={() => handlers.onCancelAlternative?.()}><div className="alternative-preview"><h4>{state.alternativeTitle}</h4><label>切换后阅读的章节<Select value={state.alternativeChapter ?? ''} disabled={state.switching === true} onChange={event => handlers.onAlternativeChapter?.(event.currentTarget.value)}>
<option key="empty" value="">请选择章节</option>{state.alternativeChapters.map(chapter => <option key={chapter.id} value={chapter.id}>{chapter.title}</option>)}
</select></label>
</Select></label>
{state.alternativeLatest && <p>最新章节:{state.alternativeLatest}</p>}
<Button disabled={state.switching || state.alternativeQualityBusy || !state.alternativeChapter} onClick={() => handlers.onCheckAlternative?.()}>{state.alternativeQualityBusy ? '正在检测正文…' : '检测所选章节 / 重试'}</Button>
{state.alternativeQuality && <div role="status" className="chapter-quality"><strong>正文检测通过</strong><p>{state.alternativeQuality.characters} 字符 · {state.alternativeQuality.images} 张图片 · {(state.alternativeQuality.bytes / 1024).toFixed(1)} KiB · {state.alternativeQuality.elapsedMs} ms</p><p>检测仅说明本次内容可读,不保证章节完整或与原书一致。</p></div>}
@@ -1070,13 +1071,13 @@ function SelectRow({
return (
<div className="field">
<label>{label}</label>
<select aria-label={label} value={value} onChange={(event) => onChange((event.currentTarget as HTMLSelectElement).value)}>
<Select aria-label={label} value={value} onChange={(event) => onChange((event.currentTarget as HTMLSelectElement).value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Select>
</div>
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Select } from './select.tsx';
import { Icon } from './icon.tsx';
import type { ReadingOverrides } from './reading-overrides.ts';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -174,7 +175,7 @@ export function ReadingTools(props: ReadingToolsProps) {
<button className="button" disabled={saving || props.overrides.version === 0} onClick={() => void edit(async () => { await props.undoOverrides(); setMessage('已撤销上一次整理修改'); })}>撤销上一次整理修改</button>
{props.manifest.book.format === 'txt' && <>
<h3>TXT 目录规则</h3><p>匹配以指定文字开头、长度不超过 80 字的行;留空恢复自动识别。应用会重新划分章节并从首章打开,旧笔记仍保留。</p>
<label>常用模板<select value="" onChange={e => { setPrefix(e.currentTarget.value); setHeadingPreview(null); }}><option value="">自动识别</option><option value="第">中文章回</option><option value="Chapter ">英文 Chapter</option><option value="【">方括号标题</option></select></label>
<label>常用模板<Select value="" onChange={e => { setPrefix(e.currentTarget.value); setHeadingPreview(null); }}><option value="">自动识别</option><option value="第">中文章回</option><option value="Chapter ">英文 Chapter</option><option value="【">方括号标题</option></Select></label>
<label>标题开头<input maxLength={60} value={prefix} onInput={e => { setPrefix(e.currentTarget.value); setHeadingPreview(null); }} /></label>
<button className="button" disabled={saving} onClick={() => void edit(async () => setHeadingPreview(await props.previewHeadings(prefix)))}>预览目录</button>
{headingPreview && <><p>识别 {headingPreview.length} 个章节,展示前 100 项</p><ol>{headingPreview.slice(0,100).map((title,i) => <li key={i}>{title}</li>)}</ol><button className="button" disabled={saving} onClick={() => void edit(async () => { await props.saveOverrides({ ...props.overrides, headingPrefix:prefix }); setMessage('目录规则已保存'); })}>应用预览规则</button></>}
+124
View File
@@ -0,0 +1,124 @@
import { Icon } from './icon.tsx';
import type { SelectHTMLAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import '../styles/select.css';
type Choice={value:string;label:string;disabled:boolean;group:string};
type Props=Omit<SelectHTMLAttributes<HTMLSelectElement>,'ref'|'multiple'|'size'> & {
variant?:'field'|'plain';
controlRef?:{current:HTMLButtonElement|null};
};
let nextId=0;
/**
* The one drop-down: native form semantics with one themed, keyboard-accessible visual
* control. A drop-in for `<select>` — same props, `<option>` children, `onChange` with
* the native event — because the native element is still there, hidden, and remains the
* value's owner (forms, validation, tests that set its value). Started in 影音 as
* `MediaSelect`; every screen uses it now (ui.md §10).
*/
export function Select({variant='field',controlRef,...props}:Props){
const native=useRef<HTMLSelectElement>(null),trigger=useRef<HTMLButtonElement>(null),panel=useRef<HTMLDivElement>(null),host=useRef<HTMLSpanElement>(null);
const id=useRef('media-select-'+ ++nextId).current;
const [choices,setChoices]=useState<Choice[]>([]),[selected,setSelected]=useState(''),[open,setOpen]=useState(false),[active,setActive]=useState(-1),[label,setLabel]=useState(''),[validation,setValidation]=useState('');
const search=useRef({text:'',at:0});
const defaultApplied=useRef(false);
const {children,className,id:fieldId,onChange,onFocus,onInvalid,onClick,...attributes}=props;
const supportsPopover=typeof HTMLElement!=='undefined'&&'showPopover' in HTMLElement.prototype;
function sync(){
const select=native.current;if(!select)return;
const values=Array.from(select.options).filter(option=>!option.hidden).map(option=>({value:option.value,label:option.label,disabled:option.disabled||Boolean(option.closest('optgroup')?.disabled),group:option.closest('optgroup')?.label||''}));
setChoices(current=>JSON.stringify(current)===JSON.stringify(values)?current:values);setSelected(select.value);
if(!props['aria-label']&&!props['aria-labelledby']){
const enclosing=host.current?.closest('label')?.cloneNode(true) as HTMLLabelElement|undefined;
enclosing?.querySelector('.media-select-control')?.remove();setLabel(enclosing?.textContent?.trim()||String(props.name||'选择选项'));
}
}
useLayoutEffect(()=>{
const select=native.current;
if(select&&!defaultApplied.current&&props.value===undefined&&props.defaultValue!==undefined&&select.options.length){
for(const option of select.options)option.defaultSelected=option.value===String(props.defaultValue);
select.value=String(props.defaultValue);defaultApplied.current=true;
}
sync();
},[children,props.value,props.defaultValue]);
useLayoutEffect(()=>{if(controlRef)controlRef.current=trigger.current;return()=>{if(controlRef)controlRef.current=null;};},[controlRef]);
useLayoutEffect(()=>{if(props.disabled)setOpen(false);},[props.disabled]);
function position(){
const button=trigger.current,menu=panel.current;if(!button||!menu)return;
const rect=button.getBoundingClientRect(),viewport=window.visualViewport;
const left=viewport?.offsetLeft||0,top=viewport?.offsetTop||0,width=viewport?.width||window.innerWidth,height=viewport?.height||window.innerHeight;
const gap=6,edge=8,menuWidth=Math.min(Math.max(rect.width,192),width-edge*2);
const below=top+height-rect.bottom-gap-edge,above=rect.top-top-gap-edge;
const upwards=below<180&&above>below,space=Math.max(44,upwards?above:below);
menu.style.width=menuWidth+'px';menu.style.maxHeight=Math.min(320,space)+'px';
menu.style.left=Math.max(left+edge,Math.min(rect.left,left+width-menuWidth-edge))+'px';
menu.style.top=(upwards?Math.max(top+edge,rect.top-gap-Math.min(menu.scrollHeight,320,space)):rect.bottom+gap)+'px';
}
useLayoutEffect(()=>{
if(!open)return;
const menu=panel.current;if(!menu)return;
if(supportsPopover)menu.showPopover();position();
const outside=(event:Event)=>{if(!host.current?.contains(event.target as Node))setOpen(false);};
const close=()=>setOpen(false);
document.addEventListener('pointerdown',outside,true);document.addEventListener('focusin',outside,true);
window.addEventListener('resize',position);window.addEventListener('scroll',position,true);
window.visualViewport?.addEventListener('resize',position);window.visualViewport?.addEventListener('scroll',position);
native.current?.form?.addEventListener('reset',close);
return()=>{
if(supportsPopover&&menu.matches(':popover-open'))menu.hidePopover();
document.removeEventListener('pointerdown',outside,true);document.removeEventListener('focusin',outside,true);
window.removeEventListener('resize',position);window.removeEventListener('scroll',position,true);
window.visualViewport?.removeEventListener('resize',position);window.visualViewport?.removeEventListener('scroll',position);
native.current?.form?.removeEventListener('reset',close);
};
},[open,choices]);
useLayoutEffect(()=>{if(open&&active>=0)panel.current?.querySelector<HTMLElement>('[data-index="'+active+'"]')?.scrollIntoView?.({block:'nearest'});},[open,active]);
useLayoutEffect(()=>{
const form=native.current?.form;if(!form)return;
const reset=()=>{setValidation('');queueMicrotask(sync);};form.addEventListener('reset',reset);return()=>form.removeEventListener('reset',reset);
},[]);
function show(){
if(props.disabled)return;
const current=choices.findIndex(choice=>choice.value===selected&&!choice.disabled);
setActive(current>=0?current:choices.findIndex(choice=>!choice.disabled));setOpen(true);search.current={text:'',at:0};
}
function choose(index:number){
const choice=choices[index],select=native.current;if(!choice||choice.disabled||!select)return;
if(select.value!==choice.value){select.value=choice.value;select.dispatchEvent(new Event('change',{bubbles:true}));}
setOpen(false);trigger.current?.focus();
}
function keyboard(event:ReactKeyboardEvent<HTMLButtonElement>){
if(event.key==='Escape'&&open){event.preventDefault();event.stopPropagation();setOpen(false);return;}
if(event.key==='Tab'){setOpen(false);return;}
if(['ArrowDown','ArrowUp','Home','End'].includes(event.key)){
event.preventDefault();if(!open){show();if(event.key!=='Home'&&event.key!=='End')return;}
const enabled=choices.map((choice,index)=>choice.disabled?-1:index).filter(index=>index>=0);
const offset=enabled.indexOf(active),step=event.key==='ArrowUp'?-1:1;
setActive(event.key==='Home'?enabled[0]??-1:event.key==='End'?enabled.at(-1)??-1:enabled[Math.max(0,Math.min(enabled.length-1,offset+step))]??-1);return;
}
if(event.key==='Enter'||event.key===' '){event.preventDefault();if(open)choose(active);else show();return;}
if(event.key.length===1&&!event.ctrlKey&&!event.metaKey&&!event.altKey){
event.preventDefault();if(!open)show();
const at=Date.now(),previous=at-search.current.at<600?search.current.text:'';
const text=(previous+event.key).toLocaleLowerCase();search.current={text,at};
const match=choices.findIndex(choice=>!choice.disabled&&choice.label.toLocaleLowerCase().startsWith(text));if(match>=0)setActive(match);
}
}
return <span ref={host} className={'media-select-control '+(className||'')} data-variant={variant} data-open={open||undefined}>
<select {...attributes} id={fieldId?String(fieldId)+'-native':undefined} className="media-select-native" ref={native} aria-hidden="true" tabIndex={-1} onClick={event=>{event.preventDefault();trigger.current?.focus();show();onClick?.(event);}} onFocus={event=>{trigger.current?.focus();onFocus?.(event);}} onInvalid={event=>{event.preventDefault();setValidation(event.currentTarget.validationMessage||'请选择一项');trigger.current?.focus();onInvalid?.(event);}} onChange={event=>{setValidation('');sync();onChange?.(event);}}>{children}</select>
<button ref={trigger} id={fieldId} type="button" className="media-select-trigger" role="combobox" aria-label={props['aria-label']||label||undefined} aria-labelledby={props['aria-labelledby']} aria-describedby={[props['aria-describedby'],validation?id+'-error':''].filter(Boolean).join(' ')||undefined} aria-invalid={props['aria-invalid']||(validation?true:undefined)} aria-required={props.required?true:undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={id} aria-activedescendant={open&&active>=0?id+'-'+active:undefined} disabled={props.disabled} title={props.title} onClick={()=>open?setOpen(false):show()} onKeyDown={keyboard}>
<span>{choices.find(choice=>choice.value===selected)?.label||'请选择'}</span><Icon name="chevron-down" size={16} />
</button>
{validation&&<span id={id+'-error'} className="media-select-error" role="alert">{validation}</span>}
{open&&<div ref={panel} id={id} className="media-select-menu" role="listbox" aria-label={props['aria-label']||label||undefined} aria-labelledby={props['aria-labelledby']} {...(supportsPopover?{popover:'manual' as const}:{})} onPointerDown={event=>event.preventDefault()}>
{choices.map((choice,index)=><div key={`${choice.group}\u0000${choice.value}\u0000${choice.label}`} id={id+'-'+index} data-index={index} className="media-select-option" role="option" aria-selected={choice.value===selected} aria-disabled={choice.disabled||undefined} data-active={index===active||undefined} onPointerMove={()=>!choice.disabled&&setActive(index)} onClick={event=>{event.preventDefault();event.stopPropagation();choose(index);}}><span>{choice.group&&<small>{choice.group}</small>}{choice.label}</span>{choice.value===selected&&<Icon name="check" size={16} />}</div>)}
</div>}
</span>;
}
/** The name it had while only 影音 used it. */
export const MediaSelect = Select;
+3 -2
View File
@@ -1,3 +1,4 @@
import { Select } from './select.tsx';
import { useEffect, useState } from 'react';
import type { ReaderApi } from '../api/client.ts';
import type { ManagedUser, RegistrationSettings } from '../api/types.ts';
@@ -52,7 +53,7 @@ export function UserManagement({ api,onSaved }: { api: ReaderApi;onSaved?:()=>vo
<label>用户名<input required minLength={3} maxLength={100} value={username} onInput={e => setUsername(e.currentTarget.value)} /></label>
<label>初始密码<input required type="password" autoComplete="new-password" minLength={8} maxLength={1024} value={password} onInput={e => setPassword(e.currentTarget.value)} /></label>
<label>显示名<input maxLength={100} value={displayName} onInput={e => setDisplayName(e.currentTarget.value)} /></label>
<label>账号角色<select value={role} onChange={e => setRole(e.currentTarget.value as 'admin' | 'member')}><option value="member">普通用户</option><option value="admin">管理员</option></select></label>
<label>账号角色<Select value={role} onChange={e => setRole(e.currentTarget.value as 'admin' | 'member')}><option value="member">普通用户</option><option value="admin">管理员</option></Select></label>
<label className="business-setting-toggle">允许使用影音<input type="checkbox" checked={mediaEnabled??defaultMedia} onChange={event=>setMediaEnabled(event.currentTarget.checked)}/></label>
<button className="button primary" disabled={busy}>创建账号</button>
</form>}
@@ -78,7 +79,7 @@ export function RegistrationManagement({ api }: { api: ReaderApi }) {
return <div className="user-management">
{notice}
{settings && <>
<label className="user-manager-mode">注册方式<select aria-label="注册方式" value={settings.mode} disabled={busy} onChange={e => { const mode = e.currentTarget.value as RegistrationSettings['mode']; confirm({ text: `将注册方式修改为${mode === 'closed' ? '关闭注册' : mode === 'open' ? '开放注册(任何访问者都可创建账号)' : '仅邀请码注册'}?`, run: () => api.setRegistrationMode(mode) }); }}><option value="closed">关闭注册</option><option value="open">开放注册</option><option value="invite">仅邀请码注册</option></select></label>
<label className="user-manager-mode">注册方式<Select aria-label="注册方式" value={settings.mode} disabled={busy} onChange={e => { const mode = e.currentTarget.value as RegistrationSettings['mode']; confirm({ text: `将注册方式修改为${mode === 'closed' ? '关闭注册' : mode === 'open' ? '开放注册(任何访问者都可创建账号)' : '仅邀请码注册'}?`, run: () => api.setRegistrationMode(mode) }); }}><option value="closed">关闭注册</option><option value="open">开放注册</option><option value="invite">仅邀请码注册</option></Select></label>
<p className="muted">关闭注册时仍可由管理员创建账号。设置立即生效并在重启后保留。</p>
<details><summary>创建邀请码</summary><form className="user-manager-form" onSubmit={e => { e.preventDefault(); void work(async () => { setCode((await api.createInvite({ label, maxUses, days })).code); }, '邀请码已创建'); }}><label>备注<input maxLength={80} value={label} onInput={e => setLabel(e.currentTarget.value)} /></label><label>可用次数<input type="number" required min={1} max={1000} value={maxUses} onInput={e => setMaxUses(Number(e.currentTarget.value))} /></label><label>有效天数<input type="number" required min={1} max={365} value={days} onInput={e => setDays(Number(e.currentTarget.value))} /></label><button className="button primary" disabled={busy}>生成邀请码</button></form></details>
{code && <label className="user-manager-mode">新邀请码(仅本次显示,请复制保存)<input aria-label="新邀请码" readOnly value={code} onFocus={e => e.currentTarget.select()} /></label>}
+20
View File
@@ -0,0 +1,20 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const src = join(import.meta.dirname, '../src');
const files = (dir: string): string[] => readdirSync(dir, { withFileTypes: true })
.flatMap(entry => entry.isDirectory() ? files(join(dir, entry.name)) : entry.name.endsWith('.tsx') ? [join(dir, entry.name)] : []);
describe('下拉框', () => {
// ui.md §10: one drop-down. The two controls below own the (hidden) native element;
// a screen writing its own `<select>` gets the platform's look instead of the theme's.
it('only ui/select.tsx and ui/searchable-select.tsx render a native <select>', () => {
const owners = new Set(['ui/select.tsx', 'ui/searchable-select.tsx']);
const offenders = files(src)
.filter(file => /<select[\s>]/.test(readFileSync(file, 'utf8')))
.map(file => file.slice(src.length + 1).replaceAll('\\', '/'))
.filter(file => !owners.has(file));
expect(offenders).toEqual([]);
});
});
+7 -7
View File
@@ -26,7 +26,7 @@ const assert=require('node:assert/strict');
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+'/#/me/admin/services');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();
// 我的 › 服务配置 is its own admin page (no 系统设置 sheet any more).
async function open(){await page.goto(origin+'/#/me/admin/services');await page.getByRole('heading',{name:'服务配置',exact:true}).waitFor();await page.getByLabel('配置分类').selectOption('tts');}
async function open(){await page.goto(origin+'/#/me/admin/services');await page.getByRole('heading',{name:'服务配置',exact:true}).waitFor();await page.getByLabel('配置分类').and(page.locator('select')).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();
@@ -35,18 +35,18 @@ const assert=require('node:assert/strict');
await page.reload();await open();assert.equal(await page.getByLabel('语音合成接口',{exact:true}).inputValue(),tts+'/tts');
const overflow=await page.locator('.business-settings').evaluate(element=>element.scrollWidth>element.clientWidth+2);assert.equal(overflow,false,'mobile settings do not overflow');
const artifacts=join(repo,'artifacts/business-settings');await mkdir(artifacts,{recursive:true});await page.screenshot({path:join(artifacts,'http-tts-mobile.png')});
await page.getByLabel('配置分类').selectOption('tmdb');await page.getByLabel('启用 TMDB',{exact:true}).check();await page.getByLabel('读取令牌',{exact:true}).fill('test-token');
await page.getByLabel('配置分类').and(page.locator('select')).selectOption('tmdb');await page.getByLabel('启用 TMDB',{exact:true}).check();await page.getByLabel('读取令牌',{exact:true}).fill('test-token');
const tmdbSaved=page.waitForResponse(response=>response.url().endsWith('/admin/settings/tmdb')&&response.request().method()==='PATCH');await page.getByRole('button',{name:'保存配置',exact:true}).click();assert.equal((await tmdbSaved).status(),200);
const provider=await (await fetch(origin+'/api/v1/media/metadata/providers',{headers})).json();assert.equal(provider.items.find(item=>item.id==='tmdb').configured,true,'isolated media worker sees configuration without restarting');
assert.equal(await page.getByLabel('读取令牌',{exact:true}).inputValue(),'');
await page.screenshot({path:join(artifacts,'tmdb-mobile.png')});
await page.getByLabel('配置分类').selectOption('playback');
assert.equal(await page.getByLabel('OpenList 播放方式',{exact:true}).inputValue(),'auto');
await page.getByLabel('OpenList 播放方式',{exact:true}).selectOption('proxy');
await page.getByLabel('配置分类').and(page.locator('select')).selectOption('playback');
assert.equal(await page.getByLabel('OpenList 播放方式',{exact:true}).and(page.locator('select')).inputValue(),'auto');
await page.getByLabel('OpenList 播放方式',{exact:true}).and(page.locator('select')).selectOption('proxy');
const playbackSaved=page.waitForResponse(response=>response.url().endsWith('/admin/settings/playback')&&response.request().method()==='PATCH');
await page.getByRole('button',{name:'保存配置',exact:true}).click();assert.equal((await playbackSaved).status(),200);
await page.reload();await open();await page.getByLabel('配置分类').selectOption('playback');
assert.equal(await page.getByLabel('OpenList 播放方式',{exact:true}).inputValue(),'proxy');assert.deepEqual(errors,[]);
await page.reload();await open();await page.getByLabel('配置分类').and(page.locator('select')).selectOption('playback');
assert.equal(await page.getByLabel('OpenList 播放方式',{exact:true}).and(page.locator('select')).inputValue(),'proxy');assert.deepEqual(errors,[]);
console.log('PASS: mobile settings, draft TTS test and audio, save/reload, secret redaction, and live media worker configuration');
}finally{
await browser?.close();server.kill();await new Promise(resolve=>server.exitCode!==null?resolve():server.once('exit',resolve));upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve));
+1 -1
View File
@@ -81,7 +81,7 @@ try {
assert.equal(await page.locator('body').evaluate(el=>el.scrollWidth>innerWidth),false);await page.screenshot({path:join(shots,'plugin-search-mobile.png')});
await page.goto(base+'/#/book/extension-book');await page.locator('book-content').waitFor();
// 切换书源 is in the reader's top bar; each candidate card is named after its source.
await page.getByRole('button',{name:'切换书源',exact:true}).click();await page.getByRole('button',{name:/^查看此源目录/}).first().click();await page.getByLabel('切换后阅读的章节').selectOption('new-two');await page.screenshot({path:join(shots,'plugin-switch-mobile.png')});
await page.getByRole('button',{name:'切换书源',exact:true}).click();await page.getByRole('button',{name:/^查看此源目录/}).first().click();await page.getByLabel('切换后阅读的章节').and(page.locator('select')).selectOption('new-two');await page.screenshot({path:join(shots,'plugin-switch-mobile.png')});
await page.getByRole('button',{name:'确认换源并阅读',exact:true}).click();await page.getByText('测试:新源暂时不可用',{exact:true}).waitFor();assert.match(await page.locator('book-content .book-flow').textContent(),/旧书源/);
fail=false;await page.getByRole('button',{name:'确认换源并阅读',exact:true}).click();await page.getByText('已切换书源,从所选章节开头继续阅读',{exact:true}).waitFor();assert.match(await page.locator('book-content .book-flow').textContent(),/新书源/);assert.deepEqual(errors,[]);
console.log('PASS production Chromium: mobile/desktop config, generic settings, safe log output, generic filters, failed switch preserves view, successful switch lands on selected chapter');
+2 -2
View File
@@ -85,7 +85,7 @@ try{
await toggleUser('admin',false);
const toggleGlobal=async enabled=>{
await admin.goto(origin+'/#/me/admin/services');
await admin.getByLabel('配置分类',{exact:true}).selectOption('modules');
await admin.getByLabel('配置分类',{exact:true}).and(admin.locator('select')).selectOption('modules');
await admin.getByRole('checkbox',{name:'启用影音模块'}).setChecked(enabled);
await admin.getByRole('button',{name:'保存配置',exact:true}).click();
if(!enabled)await admin.getByRole('alertdialog').getByRole('button',{name:'确认',exact:true}).click();
@@ -94,7 +94,7 @@ try{
await toggleGlobal(false);
await admin.reload();
await admin.getByText('影音服务:已关闭',{exact:true}).waitFor();
await admin.getByLabel('配置分类',{exact:true}).selectOption('modules');
await admin.getByLabel('配置分类',{exact:true}).and(admin.locator('select')).selectOption('modules');
assert.equal(await admin.locator('.app-nav-list > li').count(),3);
assert.equal(await admin.locator('.media-player').count(),0);
for(const width of [390,1120]){
+2 -2
View File
@@ -61,11 +61,11 @@ try {
for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'credentials-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
await button('清除站点密码').click();await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await page.getByText('站点密码 · 未配置',{exact:false}).waitFor();assert.equal(ctx.sources.credentialStatus('quality',user.id).fields[0].configured,false);
await page.goto(base+'/#/book/'+acquired.publicationId);await page.locator('book-content p').first().waitFor();await button('切换书源').click();await button('查看此源目录:备选来源').click();
await page.getByLabel('切换后阅读的章节').selectOption('empty');await button('检测所选章节 / 重试').click();await page.getByRole('dialog').locator('[role=alert]').getByText('此章节没有可读正文',{exact:false}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'original');
await page.getByLabel('切换后阅读的章节').and(page.locator('select')).selectOption('empty');await button('检测所选章节 / 重试').click();await page.getByRole('dialog').locator('[role=alert]').getByText('此章节没有可读正文',{exact:false}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'original');
await button('确认换源并阅读').click();await page.locator('.notyf').getByText('此章节没有可读正文',{exact:false}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'original');
// Dismiss every toast the rejected switch raised.
while(await button('关闭提示').count()){await button('关闭提示').first().click();await page.waitForTimeout(400);}
await page.getByLabel('切换后阅读的章节').selectOption('one');await button('检测所选章节 / 重试').click();await page.getByText('正文检测通过',{exact:true}).waitFor();
await page.getByLabel('切换后阅读的章节').and(page.locator('select')).selectOption('one');await button('检测所选章节 / 重试').click();await page.getByText('正文检测通过',{exact:true}).waitFor();
for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'quality-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
await button('确认换源并阅读').click();await page.getByRole('dialog',{name:'切换书源'}).waitFor({state:'hidden'});await page.locator('book-content p').filter({hasText:'新来源的正文'}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'alternative');
await button('设置').click();await page.locator('.panel-fold > summary',{hasText:'电纸书预设'}).click();await button('应用电纸书预设').click();
+1 -1
View File
@@ -46,7 +46,7 @@ try {
await page.locator('button[type=submit]').click(); await page.locator('.shelf-screen').waitFor();
await page.goto(base + '/#/sources');
await page.getByRole('combobox', { name: '选择来源' }).selectOption('demo');
await page.getByRole('combobox', { name: '分组', exact: true }).selectOption('fiction');
await page.getByLabel('分组', { exact: true }).and(page.locator('select')).selectOption('fiction');
await page.getByRole('searchbox', { name: '搜索书籍', exact: true }).fill('斗破苍穹');
await mkdir(shots, { recursive: true });
for (const width of [390, 1024, 1440, 1920]) {
+2 -2
View File
@@ -33,7 +33,7 @@ export async function reviewUserManagement(context,page,base,shots) {
await member.getByRole('button',{name:'启用',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await member.getByRole('button',{name:'停用',exact:true}).waitFor();
await member.getByRole('button',{name:'重置密码',exact:true}).click();await dialog.getByLabel('新密码',{exact:true}).fill('password456');await button('确认重置密码').click();await page.getByText('密码已重置,旧会话已失效',{exact:true}).waitFor();
await page.screenshot({path:join(shots,'admin-users-390.png')});
await page.goto(base+'/#/me');await page.getByRole('link',{name:'注册与邀请'}).click();await dialog.getByLabel('注册方式').waitFor();await dialog.getByLabel('注册方式').selectOption('invite');await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await page.getByText('已保存',{exact:true}).waitFor();
await page.goto(base+'/#/me');await page.getByRole('link',{name:'注册与邀请'}).click();await dialog.getByLabel('注册方式').and(page.locator('select')).waitFor();await dialog.getByLabel('注册方式').and(page.locator('select')).selectOption('invite');await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await page.getByText('已保存',{exact:true}).waitFor();
await page.getByText('创建邀请码',{exact:true}).click();await dialog.getByLabel('备注',{exact:true}).fill('家人注册');await button('生成邀请码').click();await dialog.getByLabel('新邀请码',{exact:true}).waitFor();
const code=await dialog.getByLabel('新邀请码',{exact:true}).inputValue();assert.ok(code.length>=20);
await page.screenshot({path:join(shots,'admin-registration-390.png')});
@@ -46,7 +46,7 @@ export async function reviewUserManagement(context,page,base,shots) {
await visitor.goto(base+'/#/me');await visitor.getByRole('heading',{name:'我的'}).waitFor();
assert.equal(await visitor.getByRole('link',{name:/用户管理|注册与邀请|服务配置/}).count(),0);
} finally {await newcomer.close();}
await dialog.getByLabel('注册方式').selectOption('closed');await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await page.getByText('已保存',{exact:true}).waitFor();
await dialog.getByLabel('注册方式').and(page.locator('select')).selectOption('closed');await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await page.getByText('已保存',{exact:true}).waitFor();
const anonymous=await context.browser().newContext(), login=await anonymous.newPage();
try {await login.goto(base);await login.getByText('此实例已关闭公开注册,请使用已有账号登录。',{exact:true}).waitFor();assert.equal(await login.getByRole('button',{name:'注册新账号',exact:true}).count(),0);}finally{await anonymous.close();}
}