From 740bec6d040e9bda881ede72261138b7c1f4654e Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Thu, 20 Aug 2026 20:13:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E8=A7=86=E8=A7=89=E8=A7=84=E8=8C=83=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../export/ExportConfigurationPanel.tsx | 2 +- .../components/export/ExportContactPanel.tsx | 194 +++++--- .../components/export/ExportPreviewPanel.tsx | 2 +- src/renderer/src/components/ui/button.tsx | 26 +- src/renderer/src/components/ui/checkbox.tsx | 6 +- src/renderer/src/components/ui/input.tsx | 2 +- .../src/components/ui/radio-group.tsx | 2 +- src/renderer/src/components/ui/select.tsx | 15 +- src/renderer/src/components/ui/switch.tsx | 4 +- src/renderer/src/styles/_tokens.scss | 33 +- src/renderer/src/styles/api.scss | 36 +- src/renderer/src/styles/chat.scss | 252 +--------- src/renderer/src/styles/conversation.scss | 201 -------- src/renderer/src/styles/foundation.scss | 34 -- src/renderer/src/styles/reports-core.scss | 205 ++++---- .../src/styles/reports-templates.scss | 4 +- src/renderer/src/styles/search.scss | 446 +----------------- .../src/styles/settings-preferences.scss | 48 +- src/renderer/src/styles/settings.scss | 105 +---- src/renderer/src/styles/shell-overlays.scss | 68 --- src/renderer/src/styles/tailwind.css | 125 +++-- src/renderer/src/styles/theme.scss | 26 +- tailwind.config.cjs | 11 + tests/component/export-preview-panel.test.tsx | 26 + tests/component/ui-control-states.test.tsx | 112 +++++ tests/e2e/app.spec.ts | 56 +++ tests/e2e/support/electron.ts | 18 +- tests/e2e/visual.spec.ts | 186 +++++--- 28 files changed, 777 insertions(+), 1468 deletions(-) create mode 100644 tests/component/ui-control-states.test.tsx diff --git a/src/renderer/src/components/export/ExportConfigurationPanel.tsx b/src/renderer/src/components/export/ExportConfigurationPanel.tsx index a779006..a4c2cc6 100644 --- a/src/renderer/src/components/export/ExportConfigurationPanel.tsx +++ b/src/renderer/src/components/export/ExportConfigurationPanel.tsx @@ -177,7 +177,7 @@ export function ExportConfigurationPanel({ aria-pressed={active} className={`${ active ? 'active border-2 border-primary bg-primary/10 hover:bg-primary/15' : '' - } h-[78px] min-w-0 flex-col gap-1.5 whitespace-normal px-2 text-foreground`} + } !h-[78px] min-w-0 flex-col gap-1.5 whitespace-normal px-2 text-foreground`} disabled={!exportAll && exportContactCount > 1 && value !== 'html'} onClick={() => onFormatChange(value)} > diff --git a/src/renderer/src/components/export/ExportContactPanel.tsx b/src/renderer/src/components/export/ExportContactPanel.tsx index 09b8229..0d00719 100644 --- a/src/renderer/src/components/export/ExportContactPanel.tsx +++ b/src/renderer/src/components/export/ExportContactPanel.tsx @@ -1,6 +1,8 @@ -import React from 'react' +import React, { useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' import type { ExportContactType } from '../../../../shared/export' import { Button, Checkbox, Input, Tabs, TabsList, TabsTrigger } from '../ui' +import { SearchIcon } from '../chat/icons' import type { Contact, SelfInfo } from './exportTypes' import { displayName } from './exportUtils' @@ -27,6 +29,71 @@ interface ExportContactPanelProps { onOpenSettings: () => void } +interface ExportContactRowProps { + contact: Contact + active: boolean + pressed: boolean + selected: boolean + showSelection: boolean + disabled: boolean + style?: React.CSSProperties + onSelect: (contact: Contact) => void +} + +function ExportContactRow({ + contact, + active, + pressed, + selected, + showSelection, + disabled, + style, + onSelect +}: ExportContactRowProps): React.ReactElement { + const name = displayName(contact) + + return ( + + ) +} + export function ExportContactPanel({ contacts, filteredContacts, @@ -49,6 +116,17 @@ export function ExportContactPanel({ onToggleAllContactType, onOpenSettings }: ExportContactPanelProps): React.ReactElement { + const virtualizeContacts = filteredContacts.length >= 100 + const contactListRef = useRef(null) + // TanStack Virtual owns mutable measurements, so React Compiler must skip this hook. + // eslint-disable-next-line react-hooks/incompatible-library + const contactVirtualizer = useVirtualizer({ + count: virtualizeContacts ? filteredContacts.length : 0, + getScrollElement: () => contactListRef.current, + estimateSize: () => 60, + getItemKey: (index) => filteredContacts[index]?.md5 || index, + overscan: 10 + }) const groupCount = contacts.filter((contact) => contact.type === 'group').length const userCount = contacts.length - groupCount const selectedAllCount = @@ -69,7 +147,7 @@ export function ExportContactPanel({ className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden > - ⌕ + - ) - })} +
+ {virtualizeContacts ? ( +
+ {contactVirtualizer.getVirtualItems().map((virtualItem) => { + const contact = filteredContacts[virtualItem.index] + if (!contact) return null + const selected = selectedContactIds.includes(contact.md5) + const selectedByAll = exportAll && allContactTypes.includes(contact.type) + const visuallySelected = exportAll ? selectedByAll : selected + const atLimit = + !exportAll && + selectionMode && + !selected && + selectedContactIds.length >= selectionLimit + return ( + + ) + })} +
+ ) : ( + filteredContacts.map((contact) => { + const selected = selectedContactIds.includes(contact.md5) + const selectedByAll = exportAll && allContactTypes.includes(contact.type) + const visuallySelected = exportAll ? selectedByAll : selected + const atLimit = + !exportAll && + selectionMode && + !selected && + selectedContactIds.length >= selectionLimit + return ( + + ) + }) + )}
+ + + + + ) + + expect(screen.getByRole('button', { name: '紧凑' })).toHaveClass('h-control-compact') + expect(screen.getByRole('button', { name: '标准' })).toHaveClass('h-control-standard') + expect(screen.getByRole('button', { name: '表单' })).toHaveClass('h-control-form') + expect(screen.getByRole('textbox', { name: '表单输入' })).toHaveClass('h-control-form') + }) + + it('keeps disabled controls readable without the old opacity and surface shadow', () => { + render( + <> + + + + + + + + + ) + + for (const control of [ + screen.getByRole('button', { name: '不可用操作' }), + screen.getByRole('textbox', { name: '不可用输入' }), + screen.getByRole('checkbox', { name: '不可用多选' }), + screen.getByRole('radio', { name: '不可用单选' }), + screen.getByRole('switch', { name: '不可用开关' }) + ]) { + expect(control).toHaveClass('disabled:opacity-100') + expect(control).not.toHaveClass('shadow-surface') + } + + expect(screen.getByRole('button', { name: '不可用操作' })).toHaveClass( + 'disabled:bg-disabled-surface', + 'disabled:text-disabled-foreground', + 'disabled:!text-disabled-foreground' + ) + expect(screen.getByRole('checkbox', { name: '不可用多选' })).toHaveClass( + 'border-muted-foreground/60' + ) + expect(screen.getByRole('radio', { name: '不可用单选' })).toHaveClass( + 'border-muted-foreground/60' + ) + }) + + it('keeps selection semantics while removing text glyph indicators', async () => { + const user = userEvent.setup() + render( + <> + + + + + + + + + ) + + const trigger = screen.getByRole('combobox', { name: '时间范围' }) + expect(trigger).not.toHaveTextContent('⌄') + await user.click(trigger) + expect(await screen.findByRole('option', { name: '最近 30 天' })).not.toHaveTextContent('✓') + await user.keyboard('{Escape}') + + const checkbox = screen.getByRole('checkbox', { name: '包含图片' }) + await user.click(checkbox) + expect(checkbox).toBeChecked() + expect(checkbox).not.toHaveTextContent('✓') + + await user.click(screen.getByRole('radio', { name: '舒适密度' })) + expect(screen.getByRole('radio', { name: '舒适密度' })).toBeChecked() + + const toggle = screen.getByRole('switch', { name: '显示头像' }) + await user.click(toggle) + expect(toggle).toBeChecked() + }) +}) diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index cfd46bf..b78cbf5 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -649,6 +649,62 @@ test('EXPORT-01 multi-chat selection stays local to export and forces HTML', asy } }) +test('EXPORT-02 large contact list stays bounded and searchable', async () => { + const fixture = await launchTestApp({ largeContacts: 1500 }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.setWindowContentSize({ width: 1400, height: 772 }) + await fixture.page.getByRole('button', { name: '导出' }).click() + await expect(fixture.page.getByText('共 1,503 个')).toBeVisible() + + const contactList = fixture.page.locator('.export-contact-list') + expect(await contactList.getByRole('button').count()).toBeLessThan(50) + + const compositeButtons = [ + fixture.page.getByRole('button', { name: /^全部导出/ }), + fixture.page.getByRole('button', { name: /HTML/ }), + fixture.page.locator('.export-workspace > aside:first-child > button:last-child') + ] + for (const button of compositeButtons) { + await expect(button).toBeVisible() + expect( + await button.evaluate( + (element) => element.scrollHeight <= element.clientHeight && element.clientHeight > 32 + ) + ).toBe(true) + } + + const previewGeometryIsStable = await fixture.page + .locator('.export-workspace > aside:last-child') + .evaluate((previewPanel) => { + const scrollRegion = previewPanel.children.item(1) + const statistics = previewPanel.children.item(2) + if (!(scrollRegion instanceof HTMLElement) || !(statistics instanceof HTMLElement)) { + return false + } + const panelRect = previewPanel.getBoundingClientRect() + const scrollRect = scrollRegion.getBoundingClientRect() + const statisticsRect = statistics.getBoundingClientRect() + return ( + scrollRect.bottom <= statisticsRect.top && + statisticsRect.bottom <= panelRect.bottom && + statistics.scrollHeight <= statistics.clientHeight + ) + }) + expect(previewGeometryIsStable).toBe(true) + + await fixture.page.getByRole('textbox', { name: '搜索聊天' }).fill('性能样本 1499') + const target = contactList.getByRole('button', { name: /性能样本 1499/ }) + await expect(target).toBeVisible() + await target.click() + await expect(target).toHaveAttribute('aria-pressed', 'true') + expect(pageErrors).toEqual([]) + } finally { + await fixture.close() + } +}) + test('LAYOUT-01 core workspaces fit a narrow desktop viewport without page errors', async () => { const fixture = await launchTestApp() const pageErrors: Error[] = [] diff --git a/tests/e2e/support/electron.ts b/tests/e2e/support/electron.ts index 9cc7b98..0cc635e 100644 --- a/tests/e2e/support/electron.ts +++ b/tests/e2e/support/electron.ts @@ -23,10 +23,16 @@ export async function launchTestApp( aiFailure?: string now?: number appearanceTheme?: 'light' | 'dark' + stableUserData?: string } = {} ): Promise { - const ownsDirectory = !options.userData - const userData = options.userData || mkdtempSync(resolve(tmpdir(), 'wxe-e2e-')) + const ownsDirectory = !options.userData || Boolean(options.stableUserData) + const userData = + options.userData || + (options.stableUserData + ? resolve(options.stableUserData) + : mkdtempSync(resolve(tmpdir(), 'wxe-e2e-'))) + if (options.stableUserData) rmSync(userData, { recursive: true, force: true }) const localTestEnv = loadEnv('test', process.cwd(), 'WXE_E2E_') const configuredCloseDelay = Number( process.env.WXE_E2E_CLOSE_DELAY_MS ?? localTestEnv.WXE_E2E_CLOSE_DELAY_MS @@ -50,15 +56,21 @@ export async function launchTestApp( const page = await app.firstWindow() await page.waitForLoadState('domcontentloaded') const setWindowContentSize = async (size: { width: number; height: number }): Promise => { - await app.evaluate(({ BrowserWindow }, nextSize) => { + await app.evaluate(({ BrowserWindow, screen }, nextSize) => { const [window] = BrowserWindow.getAllWindows() if (!window) throw new Error('E2E BrowserWindow is unavailable') + const { workArea } = screen.getPrimaryDisplay() + window.setPosition(workArea.x + 40, workArea.y + 40) window.setContentSize(nextSize.width, nextSize.height) }, size) await page.waitForFunction( (nextSize) => window.innerWidth === nextSize.width && window.innerHeight === nextSize.height, size ) + await page.evaluate( + () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) } return { app, diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts index 817358c..43af264 100644 --- a/tests/e2e/visual.spec.ts +++ b/tests/e2e/visual.spec.ts @@ -4,7 +4,7 @@ import { resolve } from 'path' import { launchTestApp } from './support/electron' const baselineDirectory = resolve(`tests/e2e/__screenshots__/${process.platform}/visual.spec.ts`) -const visualViewport = { width: 1000, height: 650 } +const visualViewport = { width: 1400, height: 772 } const visualNow = Date.parse('2026-08-19T14:46:40+08:00') async function clearScreenshotFocus(page: import('@playwright/test').Page): Promise { @@ -200,6 +200,30 @@ test('API-00 Reader Skill page visual @visual', async () => { } }) +test('API-00 Reader Skill page dark visual @visual', async () => { + const fixture = await launchTestApp({ now: visualNow, appearanceTheme: 'dark' }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.setWindowContentSize(visualViewport) + await fixture.page.getByRole('button', { name: 'API' }).click() + await expect(fixture.page.getByRole('heading', { name: 'TraceMemo Reader' })).toBeVisible() + await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible() + await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', 'dark') + expect( + await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + expect(pageErrors).toEqual([]) + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot('api-center-page-dark.png', { + animations: 'disabled', + caret: 'hide' + }) + } finally { + await fixture.close() + } +}) + test('API-01 Reader Skill preview visual @visual', async () => { const fixture = await launchTestApp({ now: visualNow }) const pageErrors: Error[] = [] @@ -259,77 +283,99 @@ test('API-02 Agent target segmented control visual @visual', async () => { } }) -test('REPORT-00 report configuration controls visual @visual', async () => { - const fixture = await launchTestApp({ now: visualNow }) - const pageErrors: Error[] = [] - fixture.page.on('pageerror', (error) => pageErrors.push(error)) - try { - await fixture.setWindowContentSize(visualViewport) - await fixture.page.getByRole('button', { name: '日报' }).click() - await fixture.page.getByRole('button', { name: '开始生成日报' }).click() - await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() - await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible() - await expect(fixture.page.getByRole('radiogroup', { name: '总结范围' })).toBeVisible() - expect( - await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) - ).toBe(true) - expect(pageErrors).toEqual([]) - await clearScreenshotFocus(fixture.page) - await expect(fixture.page).toHaveScreenshot('report-config-page.png', { - animations: 'disabled', - caret: 'hide' - }) +for (const appearanceTheme of ['light', 'dark'] as const) { + test(`REPORT-00 report configuration controls ${appearanceTheme} visual @visual`, async () => { + const fixture = await launchTestApp({ now: visualNow, appearanceTheme }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.setWindowContentSize(visualViewport) + await fixture.page.getByRole('button', { name: '日报' }).click() + await fixture.page.getByRole('button', { name: '开始生成日报' }).click() + await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() + await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible() + await expect(fixture.page.getByRole('radiogroup', { name: '总结范围' })).toBeVisible() + expect( + await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + expect(pageErrors).toEqual([]) + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot( + `${appearanceTheme === 'light' ? 'report-config-page' : 'report-config-page-dark'}.png`, + { + animations: 'disabled', + caret: 'hide' + } + ) - const modelHeading = fixture.page.getByRole('heading', { name: '模型配置' }) - await modelHeading.scrollIntoViewIfNeeded() - await expect(modelHeading).toBeVisible() - await clearScreenshotFocus(fixture.page) - await expect(fixture.page).toHaveScreenshot('report-model-controls.png', { - animations: 'disabled', - caret: 'hide' - }) + const modelHeading = fixture.page.getByRole('heading', { name: '模型配置' }) + await modelHeading.scrollIntoViewIfNeeded() + await expect(modelHeading).toBeVisible() + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot( + `${appearanceTheme === 'light' ? 'report-model-controls' : 'report-model-controls-dark'}.png`, + { + animations: 'disabled', + caret: 'hide' + } + ) - const templateSection = fixture.page.getByRole('heading', { name: '日报模板' }).locator('..') - await templateSection.getByRole('button', { name: '查看版式' }).first().click() - const templateDialog = fixture.page.getByRole('dialog', { name: '经典日报' }) - await expect(templateDialog).toBeVisible() - await clearScreenshotFocus(fixture.page) - await expect(fixture.page).toHaveScreenshot('report-template-dialog.png', { - animations: 'disabled', - caret: 'hide' - }) - } finally { - await fixture.close() - } -}) + if (appearanceTheme === 'light') { + const templateSection = fixture.page + .getByRole('heading', { name: '日报模板' }) + .locator('..') + await templateSection.getByRole('button', { name: '查看版式' }).first().click() + const templateDialog = fixture.page.getByRole('dialog', { name: '经典日报' }) + await expect(templateDialog).toBeVisible() + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot('report-template-dialog.png', { + animations: 'disabled', + caret: 'hide' + }) + } + } finally { + await fixture.close() + } + }) +} -test('REPORT-01 report retry controls visual @visual', async () => { - const fixture = await launchTestApp({ now: visualNow, aiFailure: '401' }) - const pageErrors: Error[] = [] - fixture.page.on('pageerror', (error) => pageErrors.push(error)) - try { - await fixture.setWindowContentSize(visualViewport) - await fixture.page.getByRole('button', { name: '日报' }).click() - await fixture.page.getByRole('button', { name: '开始生成日报' }).click() - await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() - await fixture.page.getByRole('radio', { name: '近 7 天' }).click() - await fixture.page.getByRole('button', { name: '开始生成日报' }).click() - await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible() - await expect(fixture.page.getByRole('combobox', { name: '切换模型' })).toBeVisible() - await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled() - expect( - await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) - ).toBe(true) - expect(pageErrors).toEqual([]) - await clearScreenshotFocus(fixture.page) - await expect(fixture.page).toHaveScreenshot('report-retry-controls.png', { - animations: 'disabled', - caret: 'hide' +for (const appearanceTheme of ['light', 'dark'] as const) { + test(`REPORT-01 report retry controls ${appearanceTheme} visual @visual`, async () => { + const fixture = await launchTestApp({ + now: visualNow, + aiFailure: '401', + appearanceTheme, + stableUserData: '/tmp/tracememo-e2e-report-user-data' }) - } finally { - await fixture.close() - } -}) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.setWindowContentSize(visualViewport) + await fixture.page.getByRole('button', { name: '日报' }).click() + await fixture.page.getByRole('button', { name: '开始生成日报' }).click() + await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click() + await fixture.page.getByRole('radio', { name: '近 7 天' }).click() + await fixture.page.getByRole('button', { name: '开始生成日报' }).click() + await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible() + await expect(fixture.page.getByRole('combobox', { name: '切换模型' })).toBeVisible() + await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled() + expect( + await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + expect(pageErrors).toEqual([]) + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot( + `${appearanceTheme === 'light' ? 'report-retry-controls' : 'report-retry-controls-dark'}.png`, + { + animations: 'disabled', + caret: 'hide' + } + ) + } finally { + await fixture.close() + } + }) +} for (const appearanceTheme of ['light', 'dark'] as const) { test(`CHAT-02 personal WeChat send dialog ${appearanceTheme} visual @visual`, async () => { @@ -568,6 +614,10 @@ for (const appearanceTheme of ['light', 'dark'] as const) { await expect(fixture.page.getByRole('heading', { name: '新增供应商' })).toBeVisible() await fixture.page.getByLabel('供应商 ID').fill('fixture-new-provider') await expect(fixture.page.getByRole('combobox', { name: '快速模板' })).toBeVisible() + // Pin the screenshot after fill() auto-scroll to avoid half-pixel capture drift. + await fixture.page + .locator('.settings-page-scroll') + .evaluate((element) => (element.scrollTop = 396)) expect( await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) ).toBe(true)