diff --git a/docs/ui.md b/docs/ui.md
index 27b05ae..e9f8757 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -419,7 +419,26 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
关闭后回到 `body`;消息不抢焦点。提示出现、更新和关闭不得改变内容区尺寸或滚动位置,
这些行为在手机和桌面 Chromium 中验证。数量摘要、搜索统计和操作说明继续作为页面内容展示。
-### 3.7 半屏面板
+### 3.7 弹层:只有一种 Sheet
+
+所有设置、编辑表单、选择列表、详情和确认框都用 `web/src/ui/sheet.tsx` 的 `Sheet`
+(确认框是 `ConfirmSheet`),样式只在 `web/src/styles/sheet.css`。不再使用居中弹窗、
+SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
+
+| 视口 | 形态 |
+| --- | --- |
+| ≤700px | 底部 Sheet:圆角顶边、把手,高度随内容,最高到安全区下方;阅读器内最高 62% |
+| ≥701px | 右侧抽屉:距边 1rem,宽 30rem;`size="wide"` 宽 42rem 给复杂表单 |
+| ≥701px,`size="compact"` | 确认、单行输入、短选择列表:跟手,出现在触发它的点按位置或聚焦控件的下方(放不下则上方),宽 24rem,始终留在窗口内;找不到触发点时回到右上角 |
+
+- 原生 `
} openPanel="source" onPanelChange={onPanelChange} showTrigger={false} />, root));
expect(root.querySelector('.media-resource-panel')).toBeNull();
expect(document.querySelector('dialog[open]')?.getAttribute('aria-label')).toBe('来源');
- const close = document.querySelector('dialog button[aria-label="关闭弹窗"]');
+ const close = document.querySelector('dialog button[aria-label="关闭"]');
await act(async () => close?.click());
expect(document.querySelector('dialog[open]')).toBeNull();
expect(onPanelChange).toHaveBeenCalledWith(null);
diff --git a/web/test/media-settings.test.tsx b/web/test/media-settings.test.tsx
index 6d3e861..8517a53 100644
--- a/web/test/media-settings.test.tsx
+++ b/web/test/media-settings.test.tsx
@@ -67,13 +67,13 @@ it('asks before dropping an unsaved preference draft',async()=>{
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.startsWith('浏览偏好'))!.click());
act(()=>{const select=root.querySelector('select')!;select.value='comfortable';select.dispatchEvent(new Event('change',{bubbles:true}));});
act(()=>root.querySelector('[aria-label="返回影音设置"]')!.click());
- expect(root.querySelector('[role=alert]')?.textContent).toContain('尚未保存');
+ expect(root.querySelector('[role=alertdialog]')?.textContent).toContain('尚未保存');
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='继续编辑')!.click());
expect(root.querySelector('select')!.value).toBe('comfortable');
act(()=>root.querySelector('[aria-label="返回影音设置"]')!.click());
act(()=>{const select=root.querySelector('select')!;select.value='compact';select.dispatchEvent(new Event('change',{bubbles:true}));});
act(()=>root.querySelector('[aria-label="返回影音设置"]')!.click());
- expect(root.querySelector('h1')?.textContent).toBe('影音设置');expect(root.querySelector('[role=alert]')).toBeNull();
+ expect(root.querySelector('h1')?.textContent).toBe('影音设置');expect(root.querySelector('[role=alertdialog]')).toBeNull();
});
it('retains an unsaved draft through route back/forward and discards it only explicitly',async()=>{
const onPanelChange=vi.fn();let panel:'home'|'browse'='browse';
@@ -85,13 +85,8 @@ it('retains an unsaved draft through route back/forward and discards it only exp
expect(onPanelChange).toHaveBeenLastCalledWith('browse');act(()=>{panel='browse';draw();});
expect(root.querySelector('select')!.value).toBe('comfortable');
act(()=>root.querySelector('[aria-label="返回影音设置"]')!.click());
- expect(root.querySelector('[role=alert]')?.textContent).toContain('尚未保存');
+ expect(root.querySelector('[role=alertdialog]')?.textContent).toContain('尚未保存');
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='放弃修改')!.click());
expect(onPanelChange).toHaveBeenLastCalledWith('home');act(()=>{panel='home';draw();});
act(()=>{panel='browse';draw();});expect(root.querySelector('select')!.value).toBe('compact');
});
-it('does not show playback settings when a member follows the provider deep link',async()=>{
- await act(async()=>render(,root));
- expect(root.querySelector('[role=alert]')?.textContent).toContain('需要管理员权限');
- expect(root.textContent).not.toContain('还没有正在播放');
-});
diff --git a/web/test/mine-page.test.tsx b/web/test/mine-page.test.tsx
index b52f5f1..e0c3070 100644
--- a/web/test/mine-page.test.tsx
+++ b/web/test/mine-page.test.tsx
@@ -52,10 +52,10 @@ describe('我的', () => {
it('opens the shelf display sheet in place', () => {
useAuthStore.setState({ verifiedUser: user('member') });
paint();
- const sheet = () => root.querySelector('[role=dialog][aria-label="书架设置"]')!;
- expect(sheet().hidden).toBe(true);
+ const sheet = () => root.querySelector('dialog[aria-label="书架设置"]');
+ expect(sheet()).toBeNull();
act(() => { [...root.querySelectorAll('button.mine-row')].find(b => b.textContent?.startsWith('书架显示'))!.click(); });
- expect(sheet().hidden).toBe(false);
+ expect(sheet()?.hasAttribute('open')).toBe(true);
});
it('gathers administration under 管理 for administrators and shows the chosen theme', () => {
@@ -65,8 +65,7 @@ describe('我的', () => {
const labels = [...root.querySelectorAll('.mine-row-label')].map(el => el.textContent);
expect(labels).toContain('媒体库');
const admin = [...root.querySelectorAll('[aria-label="管理"] .mine-row-label')].map(el => el.textContent);
- expect(admin).toEqual(['系统设置', '扫描与刮削', '来源与刮削']);
- expect(root.querySelector('[aria-label="管理"] a[href="/me/plugins"]')).not.toBeNull();
+ expect(admin).toEqual(['系统设置', '扫描与刮削']);
expect(root.textContent).toContain('雾蓝 · 深色');
expect(root.querySelector('.mine-profile')?.textContent).toContain('管理员');
});
diff --git a/web/test/react-router-routes.test.tsx b/web/test/react-router-routes.test.tsx
index 06a4ca9..a357dcd 100644
--- a/web/test/react-router-routes.test.tsx
+++ b/web/test/react-router-routes.test.tsx
@@ -17,7 +17,6 @@ describe('React Router route tree', () => {
['/media/queue', 'MediaQueuePage'],
['/media/music/queue', 'MediaQueuePage'],
['/media/video/settings', 'MediaSettingsPage'],
- ['/media/video/settings/plugins', 'MediaSettingsPage'],
['/media/video/settings/libraries', 'MediaLibrariesPage'],
['/media/video/settings/tasks', 'MediaTasksPage'],
['/media/video/folders/library-1', 'MediaFoldersPage'],
diff --git a/web/test/reader-chrome.test.ts b/web/test/reader-chrome.test.ts
index c35af92..d15a404 100644
--- a/web/test/reader-chrome.test.ts
+++ b/web/test/reader-chrome.test.ts
@@ -167,18 +167,18 @@ describe('ReaderChrome', () => {
it('separates appearance and behavior settings and omits text controls for comics', () => {
let tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
- expect(tree.querySelector('.panel')?.textContent).toContain('黑白灰');
- expect(tree.querySelector('.panel')?.textContent).not.toContain('点击区域');
+ expect(tree.querySelector('.sheet')?.textContent).toContain('黑白灰');
+ expect(tree.querySelector('.sheet')?.textContent).not.toContain('点击区域');
tree = paint({ settingsOpen: true, settingsTab: 'behavior' });
- expect(tree.querySelector('.panel')?.textContent).toContain('点击区域');
- expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
+ expect(tree.querySelector('.sheet')?.textContent).toContain('点击区域');
+ expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号');
tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
- expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
+ expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号');
});
it('puts the most-used appearance rows first and folds the rarely changed ones', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
- const body = tree.querySelector('.panel-body')!;
+ const body = tree.querySelector('.sheet-body')!;
const order = [...body.children].map((element) =>
element.matches('details.panel-fold')
? `fold:${element.querySelector('.panel-fold-title')?.textContent}`
@@ -247,13 +247,13 @@ describe('ReaderChrome', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
const folds = [...tree.querySelectorAll('details.panel-fold .panel-fold-title')].map((element) => element.textContent);
expect(folds).toEqual(['页眉页脚']);
- expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
- expect(tree.querySelector('.panel')?.textContent).toContain('黑白灰');
+ expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号');
+ expect(tree.querySelector('.sheet')?.textContent).toContain('黑白灰');
});
it('puts the start action first in the read-aloud sheet and folds pitch and volume', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'speech', ttsRate: 1.2, showPitchRow: true });
- const body = tree.querySelector('.panel-body')!;
+ const body = tree.querySelector('.sheet-body')!;
// The action the sheet exists for is not buried under five sliders.
expect(body.firstElementChild?.textContent).toBe('从当前位置朗读');
expect(body.firstElementChild?.classList.contains('primary')).toBe(true);
diff --git a/web/test/shelf-login-parity.test.tsx b/web/test/shelf-login-parity.test.tsx
index 8a66d24..c85eea7 100644
--- a/web/test/shelf-login-parity.test.tsx
+++ b/web/test/shelf-login-parity.test.tsx
@@ -63,13 +63,13 @@ it('keeps the shelf entry points available and restores clear, settings and OPDS
const value = mount(, runtime(), '/shelf?q=三体');
expect(root.textContent).toContain('书源');
expect(root.textContent).not.toContain('刷新');
- expect(root.textContent).toContain('设置');
+ expect(root.querySelector('button[aria-label^="书架设置"]')).not.toBeNull();
expect(root.querySelector('[aria-label="清除搜索"]')).not.toBeNull();
const search = root.querySelector('input[type="search"]')!;
await act(async () => root.querySelector('[aria-label="清除搜索"]')!.click());
expect(search.value).toBe('');
- const settingsButton = [...root.querySelectorAll('button')].find(button => button.textContent === '设置');
+ const settingsButton = root.querySelector('button[aria-label^="书架设置"]');
await act(async () => settingsButton?.click());
expect(root.textContent).toContain('连接外部阅读器');
await act(async () => [...root.querySelectorAll('button')].find(button => button.textContent === '连接外部阅读器')?.click());
diff --git a/web/test/shelf-reading-state.test.tsx b/web/test/shelf-reading-state.test.tsx
index e4ad4b2..2755d09 100644
--- a/web/test/shelf-reading-state.test.tsx
+++ b/web/test/shelf-reading-state.test.tsx
@@ -58,8 +58,8 @@ it('shows a card\'s state and marks it from the ⋯ menu', async () => {
paint('/shelf');
expect(root.querySelector('.book-state')?.textContent).toBe('在读');
await act(() => root.querySelector('.book-more')!.click());
- const options = [...root.querySelectorAll('.dialog button')].map(button => button.textContent);
- expect(options).toEqual(['标记为想读', '标记为已读', '清除标记', '从书架拿掉', '取消']);
+ const options = [...root.querySelectorAll('.sheet-list button')].map(button => button.textContent);
+ expect(options).toEqual(['标记为想读', '标记为已读', '清除标记', '从书架拿掉']);
await click('标记为已读');
expect(setReadingStatus).toHaveBeenCalledWith('b1', 'finished');
await act(() => root.querySelector('.book-more')!.click());
diff --git a/web/tools/media-navigation-review.cjs b/web/tools/media-navigation-review.cjs
index 5a0d40d..395c6cd 100644
--- a/web/tools/media-navigation-review.cjs
+++ b/web/tools/media-navigation-review.cjs
@@ -51,7 +51,7 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
assert.equal(await page.locator('.media-player audio').evaluate(a=>a.currentSrc),source);assert.equal(await page.locator('.media-player audio').evaluate(a=>a.paused),false);assert.equal(starts,1);
const mini=await page.locator('.media-player').boundingBox();assert.ok(mini.y+mini.height>814&&mini.y+mini.height<=844,'mini player must use the freed bottom area');
await page.getByRole('button',{name:'打开播放器',exact:true}).click();await page.getByRole('button',{name:'← 返回浏览',exact:true}).click();await heading('播放设置');await hash('#/media/audiobook/settings/playback');
- const member=await browser.newPage();member.on('pageerror',error=>errors.push(error.message));await member.goto(baseUrl+'/#/media/video/settings/libraries');await member.locator('input[autocomplete="username"]').fill('review-member');await member.locator('input[type="password"]').fill('review-test-pass');await member.locator('form button[type=submit]').click();await member.waitForURL('**/#/media/video/settings');await member.getByRole('heading',{name:'影音设置',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:/^媒体库管理/}).count(),0);await member.goto(baseUrl+'/#/media/video/settings/plugins');await member.getByRole('heading',{name:'需要管理员权限',exact:true}).waitFor();await member.close();
+ const member=await browser.newPage();member.on('pageerror',error=>errors.push(error.message));await member.goto(baseUrl+'/#/media/video/settings/libraries');await member.locator('input[autocomplete="username"]').fill('review-member');await member.locator('input[type="password"]').fill('review-test-pass');await member.locator('form button[type=submit]').click();await member.waitForURL('**/#/media/video/settings');await member.getByRole('heading',{name:'影音设置',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:/^媒体库管理/}).count(),0);await member.goto(baseUrl+'/#/media/video/settings/libraries');await member.getByRole('heading',{name:'需要管理员权限',exact:true}).waitFor();await member.close();
assert.deepEqual(errors,[]);await fs.writeFile(join(out,'verification.json'),JSON.stringify({passed:true,shots,checks:['mobile channel switch and Escape','no mobile bottom bar','settings child reload','personal origin reload and detail return','browser back/forward','desktop one header','reading link preserved','audio source/session survives channel and settings navigation','mini player bottom offset','member deep-link permissions'],browserErrors:errors},null,2));
await fs.writeFile(join(out,'index.html'),`影音导航与路由评审频道切换与独立页面路由
本轮按最新要求移除移动端底部频道栏。频道首页标题旁下拉切换;设置及其子页、收藏、历史有独立地址。二级页统一返回和标题,个人内容标注频道。实际客户端与临时服务、测试账号资料。
${shots.map(key=>`${key}
`).join('')}`);console.log(JSON.stringify({passed:true,screenshots:shots.length}));
}finally{if(browser)await browser.close();server.stdin.end();await new Promise(resolve=>server.exitCode!==null?resolve():server.once('exit',resolve));}
diff --git a/web/tools/media-playback-review.cjs b/web/tools/media-playback-review.cjs
index 89e44d0..e7ca23c 100644
--- a/web/tools/media-playback-review.cjs
+++ b/web/tools/media-playback-review.cjs
@@ -29,7 +29,7 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
await go('#/media/video/items/'+movie.id);await page.getByRole('button',{name:'播放电影',exact:true}).click();await page.locator('.dplayer-host').waitFor();await ready('video');await page.waitForURL(url=>{const routed=new URL('http://route'+url.hash.slice(1));return routed.pathname==='/media/video/player'&&routed.searchParams.get('item')&&routed.searchParams.get('part');});
assert.equal(await page.locator('.dplayer-controller').count(),1);assert.equal(await page.locator('.media-video-timeline').count(),0);await shot('video-mobile');
await page.setViewportSize({width:390,height:844});await shot('video-mobile-narrow');assert.ok(await page.locator('.dplayer-host').evaluate(node=>node.scrollWidth<=node.clientWidth+1));assert.equal(await page.locator('.dplayer-volume-bar-wrap').isVisible(),false);assert.ok((await page.locator('.dplayer-bar-wrap').boundingBox()).width>=80);await page.setViewportSize({width:535,height:834});
- const videoUrl=page.url(),videoSource=await page.locator('.media-player video').getAttribute('src');await page.getByRole('button',{name:'字幕',exact:true}).click();await page.getByRole('dialog',{name:'字幕'}).waitFor();await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();assert.equal(await page.locator('.media-player video').getAttribute('src'),videoSource);
+ const videoUrl=page.url(),videoSource=await page.locator('.media-player video').getAttribute('src');await page.getByRole('button',{name:'字幕',exact:true}).click();await page.getByRole('dialog',{name:'字幕'}).waitFor();await page.locator('dialog[open] .sheet-close').last().click();assert.equal(await page.locator('.media-player video').getAttribute('src'),videoSource);
await page.setViewportSize({width:1120,height:900});await shot('video-desktop');
await page.locator('.dplayer-host').hover();await page.locator('.dplayer-full-icon').click();await page.waitForFunction(()=>!!document.fullscreenElement);await shot('video-fullscreen');await page.locator('.dplayer-host').hover();await page.locator('.dplayer-full-icon').click();await page.waitForFunction(()=>!document.fullscreenElement);
const saveVideo=saved();await page.locator('.media-player video').evaluate(video=>{video.currentTime=20;video.pause();});await saveVideo;await page.reload();await page.locator('.dplayer-host').waitFor();await ready('video');assert.equal(page.url(),videoUrl);assert.ok(await page.locator('.media-player video').evaluate(video=>video.currentTime>=19&&video.paused));await shot('video-restored-desktop');
diff --git a/web/tools/media-smoke.cjs b/web/tools/media-smoke.cjs
index 2605e7b..1620a37 100644
--- a/web/tools/media-smoke.cjs
+++ b/web/tools/media-smoke.cjs
@@ -62,7 +62,7 @@ const assert=require('node:assert/strict');
await readingPage.close();
const page=await browser.newPage({viewport:{width:390,height:844}}),errors=[];page.on('pageerror',e=>errors.push(e.message));
async function stopPlayback(){
- const close=page.getByRole('button',{name:'关闭播放菜单',exact:true});if(await close.isVisible())await close.click();
+ const close=page.locator('dialog[open] .sheet-close').last();if(await close.isVisible())await close.click();
const videoStop=page.getByRole('button',{name:'结束',exact:true});
if(await videoStop.isVisible()){await videoStop.click();return;}
const audioStop=page.getByRole('button',{name:'结束播放',exact:true});
@@ -410,13 +410,13 @@ const assert=require('node:assert/strict');
await page.getByLabel('更多播放选项',{exact:true}).click();
await page.getByRole('button',{name:'快进 30 秒',exact:true}).click();
await page.getByRole('button',{name:'快退 15 秒',exact:true}).click();
- await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();
+ await page.locator('dialog[open] .sheet-close').last().click();
await page.waitForFunction(()=>{const a=document.querySelector('.media-player audio');return a.paused&&Math.abs(a.currentTime-15)<.2;});
await page.getByRole('button',{name:'继续',exact:true}).click();
await page.getByRole('button',{name:'更多播放选项',exact:true}).click();
await page.getByRole('combobox',{name:'倍速',exact:true}).selectOption('1.75');
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.playbackRate),1.75);
- await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();
+ await page.locator('dialog[open] .sheet-close').last().click();
await page.getByRole('combobox',{name:'定时',exact:true}).selectOption('15');
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'15 分钟');
for(let i=0;i<50&&renewalCount===0;i++)await delay(100);
@@ -516,7 +516,7 @@ const assert=require('node:assert/strict');
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'15 分钟');
await page.getByRole('combobox',{name:'定时',exact:true}).selectOption('0');
assert.equal(await page.getByRole('combobox',{name:'定时',exact:true}).locator('option:checked').textContent(),'定时');
- await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();
+ await page.locator('dialog[open] .sheet-close').last().click();
await page.getByRole('button',{name:'字幕',exact:true}).click();
// Exercise native TextTrack events; this validates controls, not container demux support.
await page.locator('.media-player video').evaluate(video=>{
@@ -905,7 +905,7 @@ const assert=require('node:assert/strict');
const savedLiveQueue=await(await fetch(origin+'/api/v1/media/queue',{headers})).json();
assert.deepEqual(savedLiveQueue.items.filter(entry=>entry.kind==='audiobook').map(entry=>entry.partId),[chapters[1].id]);
assert.equal(await page.locator('.media-player audio').evaluate(audio=>audio.currentSrc),sourceBeforeEdit);
- await page.getByRole('button',{name:'关闭播放菜单',exact:true}).click();
+ await page.locator('dialog[open] .sheet-close').last().click();
await stopPlayback();
await page.getByRole('button',{name:'播放',exact:true}).nth(1).click();
diff --git a/web/tools/media-theme-review.cjs b/web/tools/media-theme-review.cjs
index 637f102..c95594c 100644
--- a/web/tools/media-theme-review.cjs
+++ b/web/tools/media-theme-review.cjs
@@ -33,7 +33,7 @@ const {chromium}=require('playwright'),{spawn}=require('node:child_process'),{jo
await go('#/media/video/items/'+movie.id);await page.locator('summary[aria-label="作品操作"]').click();await page.getByRole('button',{name:'版本管理',exact:true}).click();
const dialog=page.getByRole('dialog',{name:'版本管理'});await dialog.waitFor();
for(const width of [390,535,1120]){
- await page.setViewportSize({width,height:834});const header=await dialog.locator('.source-modal-header').boundingBox();assert.ok(header.height<=60,'resource header must be compact');heights.push({theme:id,width,height:header.height});
+ await page.setViewportSize({width,height:834});const header=await dialog.locator('.sheet-header').boundingBox();assert.ok(header.height<=60,'resource header must be compact');heights.push({theme:id,width,height:header.height});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);await shot('dialog-'+id+'-'+width);
}
const url=page.url();await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});assert.equal(page.url(),url);
diff --git a/web/tools/media-v2-review.cjs b/web/tools/media-v2-review.cjs
index 0d57b6b..2f59714 100644
--- a/web/tools/media-v2-review.cjs
+++ b/web/tools/media-v2-review.cjs
@@ -57,7 +57,7 @@ async function saveShot(capture){for(let attempt=0;;attempt++){try{return await
['favorites','favorites','.media-favorite-list'],['history','video/history','.media-history-row'],['folders',`music/folders?library=${album.libraryId}`,'.media-folder-row'],
['settings','music/settings','.media-settings-intro'],['themes','music/settings/theme','.media-theme-card'],['browse-settings','music/settings/browse','.media-settings-panel'],['playback-settings','music/settings/playback','.media-settings-panel'],
['libraries','music/settings/libraries','.media-library-row'],['library-edit','video/settings/libraries/new','.media-library-create'],['permissions',`video/settings/libraries/${movie.libraryId}/permissions`,'.media-permissions-form'],
- ['tasks','video/settings/tasks','.media-manager'],['metadata',`video/items/${movie.id}/metadata`,'.media-metadata-host'],['match',`video/items/${movie.id}/match`,'.media-metadata-host'],['plugins','video/settings/plugins','.media-settings'],['account','music/settings/account','.media-account-panel'],
+ ['tasks','video/settings/tasks','.media-manager'],['metadata',`video/items/${movie.id}/metadata`,'.media-metadata-host'],['match',`video/items/${movie.id}/match`,'.media-metadata-host'],['account','music/settings/account','.media-account-panel'],
];
for(const [channel,item] of [['music',album],['audiobook',book]]){await go('#/media/'+channel+'/items/'+item.id);await page.locator('.media-detail-favorite').waitFor();await page.locator('.media-detail-favorite').click();await page.waitForFunction(()=>document.querySelector('.media-detail-favorite')?.getAttribute('aria-pressed')==='true');}
const wanted=process.env.MEDIA_V2_ONLY?.split(',');
diff --git a/web/tools/ui-review/collections.mjs b/web/tools/ui-review/collections.mjs
index bf424ad..62958c7 100644
--- a/web/tools/ui-review/collections.mjs
+++ b/web/tools/ui-review/collections.mjs
@@ -67,20 +67,20 @@ try {
await page.setViewportSize({ width, height: 844 });
await page.locator('.shelf-head button[aria-label^=书架设置]').click();
const close = page.locator('.shelf-settings button[aria-label="关闭"]');
- await page.locator('.shelf-settings .panel-body').evaluate(el => { el.scrollTop = 0; });
+ await page.locator('.shelf-settings .sheet-body').evaluate(el => { el.scrollTop = 0; });
await shot('shelf-settings-' + width);
- assert.equal(await close.evaluate(el => { const r = el.getBoundingClientRect(), p = el.closest('.panel-header').getBoundingClientRect(); return p.right - r.right <= 20 && r.top >= p.top && r.bottom <= p.bottom; }), true);
+ assert.equal(await close.evaluate(el => { const r = el.getBoundingClientRect(), p = el.closest('.sheet-header').getBoundingClientRect(); return p.right - r.right <= 20 && r.top >= p.top && r.bottom <= p.bottom; }), true);
if (width >= 1280) {
const desktopPanel = await page.locator('.shelf-settings').evaluate(el => {
const r = el.getBoundingClientRect();
return { rightGap: innerWidth - r.right, width: r.width, top: r.top, bottom: innerHeight - r.bottom };
});
- assert.ok(desktopPanel.width >= 400 && desktopPanel.width <= 450, 'desktop settings should be a compact side panel');
+ assert.ok(desktopPanel.width >= 440 && desktopPanel.width <= 500, 'desktop settings should be a compact side panel');
assert.ok(desktopPanel.rightGap <= 20 && desktopPanel.top >= 10 && desktopPanel.bottom >= 10, 'desktop settings should sit inside the viewport');
assert.equal(await page.locator('.shelf-discovery').evaluate(el => getComputedStyle(el).display), 'flex');
}
await page.setViewportSize({ width, height: width >= 1024 ? 420 : 568 });
- const body = page.locator('.shelf-settings .panel-body');
+ const body = page.locator('.shelf-settings .sheet-body');
await body.evaluate(el => { el.scrollTop = 45; });
await shot('shelf-settings-sticky-' + width);
const sticky = await body.evaluate(el => {
@@ -114,7 +114,7 @@ try {
await shot('shelf-desktop-' + width);
const trigger = page.locator('.shelf-head button[aria-label^=书架设置]');
await trigger.click();
- await page.locator('.shelf-settings .panel-body').evaluate(el => { el.scrollTop = 0; });
+ await page.locator('.shelf-settings .sheet-body').evaluate(el => { el.scrollTop = 0; });
await shot('settings-desktop-' + width);
if (width === 1366) {
await page.evaluate(() => document.documentElement.dataset.theme = 'dark');
diff --git a/web/tools/ui-review/desktop-reader.mjs b/web/tools/ui-review/desktop-reader.mjs
index a8c55cc..2eedc92 100644
--- a/web/tools/ui-review/desktop-reader.mjs
+++ b/web/tools/ui-review/desktop-reader.mjs
@@ -47,7 +47,7 @@ try {
const before = await surface();
const trigger = page.locator('.reader-actions').getByRole('button', { name: label, exact: true });
await trigger.click();
- const panel = page.locator('.reader-screen .panel');
+ const panel = page.locator('.reader-screen .sheet');
await panel.waitFor();
await shot(label + '-' + width);
const bounds = await panel.evaluate(el => {
@@ -55,9 +55,9 @@ try {
return { left: r.left, rightGap: innerWidth - r.right, width: r.width, top: r.top, bottomGap: innerHeight - r.bottom,
overflow: el.scrollWidth > el.clientWidth };
});
- assert.ok(bounds.width <= 420 && bounds.width >= 360, 'bounded panel width');
+ assert.ok(bounds.width <= 500 && bounds.width >= 440, 'bounded panel width');
assert.ok(bounds.top >= 10 && bounds.bottomGap >= 10 && !bounds.overflow, 'panel fits viewport');
- assert.ok((label === '目录' ? bounds.left : bounds.rightGap) <= 20, 'contents on left, settings on right');
+ assert.ok(bounds.rightGap <= 20, 'every reader sheet is the right-edge drawer');
assert.deepEqual(await surface(), before, 'opening ' + label + ' preserves the reading position and geometry');
await panel.getByRole('button', { name: '关闭', exact: true }).press('Escape');
await panel.waitFor({ state: 'detached' });
@@ -73,28 +73,28 @@ try {
await page.locator('.reader-screen[data-chrome=visible]').waitFor();
}
await page.locator('.reader-actions').getByRole('button', {name:'设置',exact:true}).click();
- await page.locator('.panel').getByRole('button',{name:'翻页',exact:true}).click();
- await page.locator('.panel button[aria-label="关闭"]').click();
+ await page.locator('.sheet').getByRole('button',{name:'翻页',exact:true}).click();
+ await page.locator('.sheet button[aria-label="关闭"]').click();
await page.locator('.progress-scrubber').fill('2');
await page.waitForFunction(() => document.querySelector('.progress-page')?.textContent?.includes('第 2/'));
await shot('paged');
const paged = await surface();
await page.locator('.reader-actions').getByRole('button',{name:'目录',exact:true}).click();
- await page.locator('.panel button[aria-label="关闭"]').press('Escape');
+ await page.locator('.sheet button[aria-label="关闭"]').press('Escape');
assert.deepEqual(await surface(),paged,'side panels preserve the selected page in paged mode');
await page.getByRole('button',{name:'夜间',exact:true}).click();
await shot('dark');
await page.locator('.reader-actions').getByRole('button',{name:'外观',exact:true}).click();
await shot('dark-settings');
- await page.locator('.panel button[aria-label="关闭"]').click();
+ await page.locator('.sheet button[aria-label="关闭"]').click();
await page.getByRole('button',{name:'日间',exact:true}).click();
await page.setViewportSize({ width: 1024, height: 420 });
await page.locator('.reader-actions').getByRole('button', { name: '外观', exact: true }).click();
- const body = page.locator('.panel-body');
+ const body = page.locator('.sheet-body');
await body.hover(); await page.mouse.wheel(0, 300);
- await page.waitForFunction(() => document.querySelector('.panel-body')?.scrollTop > 0);
+ await page.waitForFunction(() => document.querySelector('.sheet-body')?.scrollTop > 0);
await shot('short-window-scroll');
- await page.locator('.panel button[aria-label="关闭"]').click();
+ await page.locator('.sheet button[aria-label="关闭"]').click();
assert.deepEqual(errors, []);
console.log('PASS desktop reader: 1024/1366/1920px, four panels, Escape/focus, immersive mode, stable page geometry and short-window scrolling');
} finally {
diff --git a/web/tools/ui-review/p1-reading.mjs b/web/tools/ui-review/p1-reading.mjs
index 32c1454..e92331f 100644
--- a/web/tools/ui-review/p1-reading.mjs
+++ b/web/tools/ui-review/p1-reading.mjs
@@ -60,9 +60,9 @@ try {
assert.ok(await page.locator('book-content').evaluate(host=>host.shadowRoot.querySelectorAll('[data-reader-mark="search"]').length)>0);
await button('工具').click();await button('返回原阅读位置').click();
await button('目录').click();await page.getByRole('button',{name:'第三层',exact:false}).last().waitFor();await page.getByRole('button',{name:'第一层 折叠',exact:true}).click();assert.equal(await page.getByRole('button',{name:'第三层',exact:false}).count(),0);await page.getByRole('button',{name:'第一层 展开',exact:true}).click();await page.getByRole('button',{name:/3.*第三层/}).click();
- await button('工具').click();await button('离线缓存').click();await button('下载 / 继续').click();await page.getByText('整书已可离线阅读',{exact:false}).waitFor();await page.screenshot({path:join(shots,'offline.png')});await button('关闭弹窗').click();
+ await button('工具').click();await button('离线缓存').click();await button('下载 / 继续').click();await page.getByText('整书已可离线阅读',{exact:false}).waitFor();await page.screenshot({path:join(shots,'offline.png')});await page.locator('dialog[open] .sheet-close').last().click();
await context.route('**/api/**',route=>route.abort('internetdisconnected'));await page.reload();await page.locator('book-content h1').waitFor();
- await button('工具').click();await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();await page.getByText('离线搜索(仅已缓存内容)',{exact:false}).waitFor();await button('关闭弹窗').click();await context.unroute('**/api/**');
+ await button('工具').click();await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();await page.getByText('离线搜索(仅已缓存内容)',{exact:false}).waitFor();await page.locator('dialog[open] .sheet-close').last().click();await context.unroute('**/api/**');
// Select actual shadow DOM text, then create a persisted personal correction.
await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
await page.getByRole('toolbar',{name:'选中文字操作'}).waitFor();await page.screenshot({path:join(shots,'selection-toolbar-390.png')});
@@ -89,8 +89,8 @@ try {
await page.getByRole('toolbar',{name:'选中文字操作'}).waitFor();await button('关闭选区工具').click();
await page.locator('book-content').evaluate(host=>{delete host.shadowRoot.getSelection;});
await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
- await button('工具').click();await button('内容整理').click();await page.getByLabel('替换为',{exact:true}).fill('已经修正');await button('保存纠错 / 过滤').click();await page.getByText('纠错已保存,正文已更新').waitFor();await button('关闭弹窗').click();await page.reload();await page.locator('book-content p').filter({hasText:'已经修正'}).waitFor();
- await button('工具').click();await button('内容整理').click();await button('撤销上一次整理修改').click();await page.getByText('已撤销上一次整理修改').waitFor();await button('关闭弹窗').click();assert.ok(!(await page.locator('book-content p').first().innerText()).includes('已经修正'));
+ await button('工具').click();await button('内容整理').click();await page.getByLabel('替换为',{exact:true}).fill('已经修正');await button('保存纠错 / 过滤').click();await page.getByText('纠错已保存,正文已更新').waitFor();await page.locator('dialog[open] .sheet-close').last().click();await page.reload();await page.locator('book-content p').filter({hasText:'已经修正'}).waitFor();
+ await button('工具').click();await button('内容整理').click();await button('撤销上一次整理修改').click();await page.getByText('已撤销上一次整理修改').waitFor();await page.locator('dialog[open] .sheet-close').last().click();assert.ok(!(await page.locator('book-content p').first().innerText()).includes('已经修正'));
await page.goto(base+'/#/book/'+txt.id);await page.locator('book-content p').first().waitFor();await button('工具').click();await button('内容整理').click();await page.getByLabel('标题开头').fill('@@');await button('预览目录').click();await button('应用预览规则').waitFor();await page.screenshot({path:join(shots,'txt-preview.png')});await button('应用预览规则').click();await page.getByText('目录规则已保存').waitFor();
assert.deepEqual(errors,[]);console.log('P1 browser flow passed: search/navigation/highlight, 320–1280px, nested TOC, offline EPUB reopen, persisted correction/undo, TXT preview/apply');
}finally{await browser?.close();await app?.close();db?.close();const cleanupRelative=relative(resolve(tmpdir()),resolve(root));assert.ok(!cleanupRelative.startsWith('..')&&!isAbsolute(cleanupRelative)&&cleanupRelative.startsWith('reader-p1-'));await rm(root,{recursive:true,force:true});}
diff --git a/web/tools/ui-review/p2-sources.mjs b/web/tools/ui-review/p2-sources.mjs
index 3b70bb9..19dd71b 100644
--- a/web/tools/ui-review/p2-sources.mjs
+++ b/web/tools/ui-review/p2-sources.mjs
@@ -44,9 +44,9 @@ try {
const opdsUser=await page.getByLabel('OPDS 用户名',{exact:true}).inputValue(),opdsPassword=await page.getByLabel('OPDS 密码',{exact:true}).inputValue();
const authorization='Basic '+Buffer.from(opdsUser+':'+opdsPassword).toString('base64');
const feed=await fetch(base+'/opds',{headers:{authorization}});assert.equal(feed.status,200);assert.match(await feed.text(),/opds-test/);
- await button('关闭弹窗').click();await button('连接外部阅读器').click();await button('撤销 浏览器验收客户端').waitFor();assert.equal(await page.getByLabel('OPDS 密码',{exact:true}).count(),0);
+ await page.locator('dialog[open] .sheet-close').last().click();await button('连接外部阅读器').click();await button('撤销 浏览器验收客户端').waitFor();assert.equal(await page.getByLabel('OPDS 密码',{exact:true}).count(),0);
for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'opds-access-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
- await button('撤销 浏览器验收客户端').click();await button('撤销 浏览器验收客户端').waitFor({state:'hidden'});assert.equal((await fetch(base+'/opds',{headers:{authorization}})).status,401);await button('关闭弹窗').click();
+ await button('撤销 浏览器验收客户端').click();await button('撤销 浏览器验收客户端').waitFor({state:'hidden'});assert.equal((await fetch(base+'/opds',{headers:{authorization}})).status,401);await page.locator('dialog[open] .sheet-close').last().click();
await page.goto(base+'/#/sources');assert.equal(await button('连接外部阅读器').count(),0);
await page.getByLabel('选择来源').selectOption('quality');
assert.equal(await page.locator('.source-capabilities').count(),0);
diff --git a/web/tools/ui-review/react-app.mjs b/web/tools/ui-review/react-app.mjs
index 630298c..fdee739 100644
--- a/web/tools/ui-review/react-app.mjs
+++ b/web/tools/ui-review/react-app.mjs
@@ -208,8 +208,9 @@ async function runReaderReview(browser, origin, checks, errors) {
assert.equal(await page.getByText(/已选 1 项/).count(), 0);
}
await capture(page, 'library-files', checks, errors);
- await page.goto(`${origin}/#/settings`);
- await waitHeading(page, '设置');
+ // /settings redirects to the shelf; 我的 is the settings hub now.
+ await page.goto(`${origin}/#/me`);
+ await waitHeading(page, '我的');
await capture(page, 'settings', checks, errors);
await page.goto(`${origin}/#/sources`);
await waitHeading(page, '书源');
@@ -334,7 +335,7 @@ async function runMediaReview(browser, fixture, checks, errors) {
await page.getByRole('button', { name: '清空当前频道队列' }).click();
await page.getByRole('button', { name: '确认清空' }).waitFor({ state: 'visible' });
- assert.match(await page.locator('[role="dialog"], .media-floating-confirm').last().innerText().catch(() => ''), /清空队列|确认清空/);
+ assert.match(await page.locator('dialog[open]').last().innerText().catch(() => ''), /清空队列|确认清空/);
const clear = page.waitForResponse(r => r.request().method() === 'POST' && r.url().endsWith('/api/v1/media/queue/clear'));
await page.getByRole('button', { name: '确认清空' }).click();
assert.equal((await clear).status(), 200, 'queue clear failed');
diff --git a/web/tools/ui-review/reader-regression.mjs b/web/tools/ui-review/reader-regression.mjs
index 8b83acc..89656be 100644
--- a/web/tools/ui-review/reader-regression.mjs
+++ b/web/tools/ui-review/reader-regression.mjs
@@ -46,16 +46,16 @@ try {
await cdp.screenshot(join(shots, 'reader.png'));
for (const label of ['目录', '外观', '设置', '朗读']) {
await cdp.clickText('.reader-actions button', label);
- await cdp.waitFor('document.querySelector(".panel") !== null');
- await cdp.run(`await Promise.all(document.querySelector('.panel').getAnimations().map(a=>a.finished));`);
+ await cdp.waitFor('document.querySelector(".sheet") !== null');
+ await cdp.run(`await Promise.all(document.querySelector('.sheet').getAnimations().map(a=>a.finished));`);
const opened = await read();
assert.equal(opened.y, after.y, `${label} must preserve scroll position`);
assert.equal(opened.height, after.height, `${label} must preserve surface height`);
- const panel = await cdp.run(`const r=document.querySelector('.panel').getBoundingClientRect();return {top:r.top,bottom:r.bottom};`);
+ const panel = await cdp.run(`const r=document.querySelector('.sheet').getBoundingClientRect();return {top:r.top,bottom:r.bottom};`);
assert.ok(panel.top > 250 && panel.bottom <= 845, `sheet must leave a preview and fit the screen: ${JSON.stringify(panel)}`);
await cdp.screenshot(join(shots, `panel-${label}.png`));
- await cdp.click('.panel button[aria-label="关闭"]');
- await cdp.waitFor('document.querySelector(".panel") === null');
+ await cdp.click('.sheet button[aria-label="关闭"]');
+ await cdp.waitFor('document.querySelector(".sheet") === null');
assert.equal((await read()).y, after.y, `${label} close must preserve position`);
}
await cdp.clickText('.reader-actions button', '外观');
@@ -69,7 +69,7 @@ try {
const modes = await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)');
assert.deepEqual(modes, ['book','chapter','time','progress']);
await cdp.screenshot(join(shots, 'readout-settings.png'));
- await cdp.click('.panel button[aria-label="关闭"]');
+ await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.tapMiddle();
await cdp.screenshot(join(shots, 'readout-custom.png'));
await cdp.navigate(`${origin}/#/shelf`);
@@ -84,8 +84,8 @@ try {
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
await cdp.sleep(100);
}
- await cdp.clickText('.panel button', '黑白灰');
- await cdp.click('.panel button[aria-label="关闭"]');
+ await cdp.clickText('.sheet button', '黑白灰');
+ await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.sleep(300);
assert.equal((await read()).page, after.page, 'theme must preserve page');
await cdp.tapMiddle();
@@ -100,8 +100,8 @@ try {
}
await cdp.tapMiddle();
await cdp.clickText('.reader-actions button', '设置');
- await cdp.clickText('.panel button', '翻页');
- await cdp.click('.panel button[aria-label="关闭"]');
+ await cdp.clickText('.sheet button', '翻页');
+ await cdp.click('.sheet button[aria-label="关闭"]');
await cdp.sleep(300);
await cdp.run(`const el=document.querySelector('.progress-scrubber');el.value='2';el.dispatchEvent(new Event('input',{bubbles:true}));`);
await cdp.sleep(250);
@@ -153,11 +153,11 @@ try {
await cdp.sleep(250);
for(const label of ['外观','设置']) {
await cdp.clickText('.reader-actions button', label);
- await cdp.run(`await Promise.all(document.querySelector('.panel').getAnimations().map(a=>a.finished));`);
- const overflow=await cdp.run(`const p=document.querySelector('.panel');return p.scrollWidth-p.clientWidth;`);
+ await cdp.run(`await Promise.all(document.querySelector('.sheet').getAnimations().map(a=>a.finished));`);
+ const overflow=await cdp.run(`const p=document.querySelector('.sheet');return p.scrollWidth-p.clientWidth;`);
assert.ok(overflow <= 1, `${label} fits a narrow phone`);
await cdp.screenshot(join(shots, `narrow-${label}.png`));
- await cdp.click('.panel button[aria-label="关闭"]');
+ await cdp.click('.sheet button[aria-label="关闭"]');
}
await cdp.navigate(`${origin}/#/book/review-pdf`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("iframe")?.src.startsWith("blob:")');
diff --git a/web/tools/ui-review/run.mjs b/web/tools/ui-review/run.mjs
index 976ce79..eb83cf1 100644
--- a/web/tools/ui-review/run.mjs
+++ b/web/tools/ui-review/run.mjs
@@ -170,7 +170,7 @@ async function audit(cdp, origin, scenes, results) {
// geometry step above leaves the *contents* sheet up, and a blind click on the
// settings button would close it and then wait for a sheet that is already there.
const settingsTitle = await cdp.evaluate(
- `document.querySelector('.panel')?.querySelector('.panel-title, h2, header')?.textContent?.trim() ?? ''`,
+ `document.querySelector('.sheet')?.querySelector('.sheet-header h2')?.textContent?.trim() ?? ''`,
);
if (!settingsTitle.includes('设置')) {
if (settingsTitle) {
@@ -178,7 +178,7 @@ async function audit(cdp, origin, scenes, results) {
await cdp.sleep(300);
}
await cdp.click('button[aria-label="设置"]');
- await cdp.waitFor('document.querySelector(".panel") !== null', 10_000);
+ await cdp.waitFor('document.querySelector(".sheet") !== null', 10_000);
}
await cdp.sleep(300);
await cdp.clickText('.segmented button', '滚动');
@@ -1103,7 +1103,7 @@ async function main() {
const theme = scene.theme;
if (theme) {
await cdp.click('button[aria-label="界面"]');
- await cdp.waitFor('document.querySelector(".panel") !== null');
+ await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.clickText('.segmented button', theme);
await cdp.sleep(250);
await cdp.click('button[aria-label="关闭"]');
@@ -1111,12 +1111,12 @@ async function main() {
}
if (scene.openPanel) {
await cdp.click(`button[aria-label="${scene.openPanel}"]`);
- await cdp.waitFor('document.querySelector(".panel") !== null');
+ await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.sleep(350);
}
if (scene.choose) {
await cdp.click(`button[aria-label="${scene.choose[0]}"]`);
- await cdp.waitFor('document.querySelector(".panel") !== null');
+ await cdp.waitFor('document.querySelector(".sheet") !== null');
await cdp.clickText('.segmented button', scene.choose[1]);
await cdp.sleep(300);
}
@@ -1163,17 +1163,17 @@ async function main() {
// starting position — off the bottom of the screen — and every number derived
// from it is then about the animation rather than about the layout.
await cdp.click('button[aria-label="目录"]');
- await cdp.waitFor('document.querySelector(".panel") !== null', 10_000);
+ await cdp.waitFor('document.querySelector(".sheet") !== null', 10_000);
await cdp.sleep(400);
const panelGeometry = await cdp.evaluate(`(() => {
- const panel = document.querySelector('.panel');
+ const panel = document.querySelector('.sheet');
const stage = document.querySelector('.stage');
if (!panel || !stage) return null;
const p = panel.getBoundingClientRect();
const s = stage.getBoundingClientRect();
return {
panelTop: p.top, panelHeight: p.height, stageTop: s.top, stageHeight: s.height,
- hasScrim: !!document.querySelector('.scrim'), grip: !!document.querySelector('.panel-grip'),
+ hasScrim: !!document.querySelector('dialog.sheet[open]'), grip: !!document.querySelector('.sheet-grip'),
visibleText: p.top - s.top,
};
})()`);
diff --git a/web/tools/ui-review/user-management.mjs b/web/tools/ui-review/user-management.mjs
index fba93db..4308620 100644
--- a/web/tools/ui-review/user-management.mjs
+++ b/web/tools/ui-review/user-management.mjs
@@ -13,26 +13,26 @@ export async function reviewUserManagement(context,page,base,shots) {
const member=dialog.locator('li').filter({hasText:'@family-ui'});await member.waitFor();
const before=await member.boundingBox();
await member.getByRole('button',{name:'停用',exact:true}).click();
- await page.locator('.swal2-popup').waitFor();
+ await page.locator('dialog[role=alertdialog]').waitFor();
assert.equal(await dialog.locator('.notice[role=alert]').count(),0);
assert.deepEqual(await member.boundingBox(),before);
- assert.equal(await button('取消操作').evaluate(el=>el===document.activeElement),true);
+ assert.equal(await page.locator('dialog[role=alertdialog] .sheet-cancel').evaluate(el=>el===document.activeElement),true);
await page.screenshot({path:join(shots,'floating-confirm-390.png')});
- await page.keyboard.press('Escape');await page.locator('.confirmation-host').waitFor({state:'detached'});
+ await page.keyboard.press('Escape');await page.locator('dialog[role=alertdialog]').waitFor({state:'detached'});
assert.equal(await member.getByRole('button',{name:'停用',exact:true}).evaluate(el=>el===document.activeElement),true);
- await member.getByRole('button',{name:'停用',exact:true}).click();await button('取消操作').click();
+ await member.getByRole('button',{name:'停用',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-cancel').click();
await context.route('**/api/v1/admin/users/*',route=>route.request().method()==='PATCH'?route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEST_FAILURE',message:'暂时无法保存,请重试'}})}):route.continue());
- await member.getByRole('button',{name:'停用',exact:true}).click();await button('确认操作').click();
+ await member.getByRole('button',{name:'停用',exact:true}).click();await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();
await page.locator('.notyf [role=alert]').filter({hasText:'暂时无法保存,请重试'}).waitFor();
assert.equal(await dialog.locator('.user-management > [role=alert]').count(),0);
await context.unroute('**/api/v1/admin/users/*');
- await member.getByRole('button',{name:'设为管理员',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'移除管理员',exact:true}).waitFor();
- await member.getByRole('button',{name:'移除管理员',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'设为管理员',exact:true}).waitFor();
- await member.getByRole('button',{name:'停用',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'启用',exact:true}).waitFor();
- await member.getByRole('button',{name:'启用',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'停用',exact:true}).waitFor();
+ 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 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 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 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 button('注册与邀请').click();await dialog.getByLabel('注册方式').selectOption('invite');await button('确认操作').click();await page.getByText('已保存',{exact:true}).waitFor();
+ await button('注册与邀请').click();await dialog.getByLabel('注册方式').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')});
@@ -44,8 +44,8 @@ export async function reviewUserManagement(context,page,base,shots) {
await visitor.getByRole('button',{name:'注册并登录',exact:true}).click();await visitor.locator('.shelf-screen').waitFor();
assert.equal(await visitor.getByRole('button',{name:'系统设置',exact:true}).count(),0);
} finally {await newcomer.close();}
- await dialog.getByLabel('注册方式').selectOption('closed');await button('确认操作').click();await page.getByText('已保存',{exact:true}).waitFor();
- await button('关闭弹窗').click();assert.equal(await page.getByRole('dialog',{name:'书架设置'}).isVisible(),false);
+ await dialog.getByLabel('注册方式').selectOption('closed');await page.locator('dialog[role=alertdialog] .sheet-footer .primary').click();await page.getByText('已保存',{exact:true}).waitFor();
+ await page.locator('dialog[open] .sheet-close').last().click();assert.equal(await page.getByRole('dialog',{name:'书架设置'}).isVisible(),false);
const anonymous=await context.browser().newContext(), login=await anonymous.newPage();
try {await login.goto(base);await login.getByRole('button',{name:'注册新账号',exact:true}).click();await login.getByText('此书房暂未开放注册,请联系管理员创建账号或开放注册。',{exact:true}).waitFor();assert.equal(await login.getByRole('button',{name:'注册并登录',exact:true}).isDisabled(),true);}finally{await anonymous.close();}
}