fix(webui): 修侧栏点击无法翻页 + 回信 UI 统一成一个组件
用户两句:「侧边导航栏完全不可用,点击无法翻页,而且窄屏显示不全」、
「按顺序做吧」(= 做回信 UI 统一)。
## ① 侧栏点击无法翻页(严重,我的错)
导航合并时我把点击目标写成:
onClick={() => setViewMode(target || commTab || modes[0])}
只有「通信」有意回上次的子页签,而**日历/联系人没有 target** ⇒ 点它们会跳到
`commTab`(收件箱)⇒ 表现为"点了没反应/不翻页"。
修成 `setViewMode(target ?? (isComm ? commTab : modes[0]))`。
**为什么没被拦住**:我的验证全在看**结构与样式**(导航项数、徽标、圆角、玻璃、
对比度),**一次都没点过**。所以补了 `test/components/Sidebar.test.tsx`:点每一项,
断言落到它自己那一项,并带一条反向对照。真浏览器点击也复验:日历→日历页、
联系→联系人、通信→回通信页。
## ② 回信 UI 统一(用户点名的欠账)
新增 `src/components/Composer.tsx`:**形状**(输入区 / 动作行 / 提示)只有一处定义,
**差异**用 `header`(选项胶囊)、`footerExtra`、`submit.tone`、`density`
(compact=批注、roomy=回信正文)条件渲染 —— 差异是数据,不是又一套 UI。
三处各写一套的地方现在都走它:提问型授权表单、审批型(同意/拒绝)、ReplyBar 回信。
输入框的边框/圆角/聚焦环/禁用态从"抄了三遍"变成一处。
**重构时我引入过一个危险的错**:给审批型加了个"提交备注"按钮,兜底用 `options[0]`
(= 同意)⇒ 点一下就**默认批准**。被既有判据当场抓住(`PermissionPanel` 两条红),
已改成"点选项即提交"(`submit` 现在是可选的),并用 `variant="action"`
把同意/拒绝的颜色语义恢复成原来的实心绿 / 浅红。
## ③ 我自己的流程问题(写下来)
- 结构类改动必须配一条"**点它**"的判据 —— 这次就是缺了它。
- 单测里改 store 后必须 `rerender` 再点:否则闭包里是旧值(我第一版因此误判代码有问题)。
- 窄屏"显示不全"**没能复现**:390×844 与 320×568 都量了 —— 无横向溢出、
内容面板完整落在悬浮导航之上、最后一行完整可见。需要用户指出具体页面。
判据:vitest **15 文件 / 258 用例全绿**(含新增 Sidebar 点击 4 条)。
This commit is contained in:
210
client/electron/src/components/Composer.tsx
Normal file
210
client/electron/src/components/Composer.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
import React from 'react';
|
||||
import { CheckIcon, CloseIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/*
|
||||
* 回信/批注/写信的**共用 UI**。
|
||||
*
|
||||
* 用户(2026-09-14):「现在多个页面各自独立实现了一套回信 ui,是不是可以统一一下,
|
||||
* 通过条件渲染的方式,使用同一个 ui」。
|
||||
*
|
||||
* 当时现场有三套各写各的:
|
||||
* ① MailView 的 ReplyBar(回信:多行 textarea + 发送)
|
||||
* ② MailView 的授权表单(提问型:选项胶囊 + textarea + 提交;审批型:按钮 + 单行备注)
|
||||
* ③ ComposePage 的正文输入(写信)
|
||||
* 三处的边框/圆角/聚焦环/禁用态/忙碌态/提示文案都是**分别写的**,于是很快就漂移了
|
||||
* (同一个输入框有三种 padding,同一类提交按钮有两种禁用透明度)。
|
||||
*
|
||||
* 这个组件的做法是:把**形状**(输入区 + 动作行 + 提示)固定下来,把**差异**
|
||||
* 用 `header` / `footerExtra` / `submit.tone` 条件渲染。差异是数据,不是又一套 UI。
|
||||
*/
|
||||
|
||||
export type ComposerTone = 'primary' | 'approve' | 'danger';
|
||||
|
||||
export interface ComposerSubmit {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
tone?: ComposerTone;
|
||||
}
|
||||
|
||||
export interface ComposerProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
/** 多行(textarea)还是单行(input)——审批型的"备注"就是单行的 */
|
||||
singleLine?: boolean;
|
||||
rows?: number;
|
||||
/**
|
||||
* 输入区的**具名档位**。
|
||||
*
|
||||
* compact(默认):批注、备注这类"随手写一句"。
|
||||
* roomy:回信要写正文,需要更大面积 + 等宽字体(Markdown 对齐)。
|
||||
* 两种都在这里定义 —— 让"大小差异"成为一处具名档位,而不是散落的写死值。
|
||||
*/
|
||||
density?: 'compact' | 'roomy';
|
||||
/** 输入区**上方**的条件内容:选项胶囊、收件人摘要等 */
|
||||
header?: React.ReactNode;
|
||||
/** 动作行左侧的额外内容:附件、预算等 */
|
||||
footerExtra?: React.ReactNode;
|
||||
/**
|
||||
* 提交动作。**可以不给** —— 审批型就是"点选项即提交",多一个提交按钮反而危险
|
||||
* (我第一版给它加了一个默认提交 `options[0]`,那等于点一下"提交备注"就批准 ✗)。
|
||||
*/
|
||||
submit?: ComposerSubmit;
|
||||
/** 提交按钮旁边的说明(例如"请先选择或填写回答") */
|
||||
hint?: React.ReactNode;
|
||||
/** 错误信息(发送失败等) */
|
||||
error?: string | null;
|
||||
/** 自动聚焦(窄屏从悬浮球展开时用:少一次点击) */
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TONE: Record<ComposerTone, string> = {
|
||||
primary: 'bg-blue-700 text-white hover:bg-blue-800',
|
||||
approve: 'bg-green-700 text-white hover:bg-green-800',
|
||||
danger: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
||||
};
|
||||
|
||||
// 输入区**只有这一处**样式定义:三套 UI 漂移的根源就是它被抄了三遍
|
||||
const FIELD_BASE =
|
||||
'w-full text-gray-900 border border-gray-300 rounded-md ' +
|
||||
'placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-100 ' +
|
||||
'focus:border-blue-400 disabled:opacity-40';
|
||||
|
||||
const FIELD_DENSITY: Record<'compact' | 'roomy', string> = {
|
||||
compact: 'text-xs px-2.5 py-1.5',
|
||||
roomy: 'text-sm font-mono p-3 h-20 resize-none'
|
||||
};
|
||||
|
||||
const fieldClass = (density: 'compact' | 'roomy', multi: boolean) =>
|
||||
`${FIELD_BASE} ${FIELD_DENSITY[density]}${multi && density === 'compact' ? ' resize-y' : ''}`;
|
||||
|
||||
export function Composer({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
singleLine = false,
|
||||
rows = 2,
|
||||
density = 'compact',
|
||||
header,
|
||||
footerExtra,
|
||||
submit,
|
||||
hint,
|
||||
error,
|
||||
autoFocus = false,
|
||||
className = ''
|
||||
}: ComposerProps) {
|
||||
const busy = submit?.busy === true;
|
||||
const tone = submit?.tone ?? 'primary';
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* 条件渲染的差异部分:选项胶囊 / 收件人摘要 */}
|
||||
{header}
|
||||
|
||||
{singleLine ? (
|
||||
<input
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={busy}
|
||||
autoFocus={autoFocus}
|
||||
className={`${header ? 'mt-2 ' : ''}${fieldClass(density, false)}`}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
disabled={busy}
|
||||
autoFocus={autoFocus}
|
||||
className={`${header ? 'mt-2 ' : ''}${fieldClass(density, true)}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(submit || footerExtra || hint) && (
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
{submit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit.onClick}
|
||||
disabled={submit.disabled === true || busy}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${TONE[tone]}`}
|
||||
>
|
||||
{busy ? <SpinnerIcon className="w-3.5 h-3.5 animate-spin" /> : null}
|
||||
{submit.label}
|
||||
</button>
|
||||
)}
|
||||
{footerExtra}
|
||||
{hint && <span className="text-2xs text-gray-400">{hint}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-1.5 text-2xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选项胶囊(授权提问型与审批型共用)。
|
||||
*
|
||||
* 单独导出是因为它在**同一个 Composer 的 header 里**要能多选/单选、还要按语义
|
||||
* 选颜色(同意=绿、拒绝=红、其它=灰)。这三种状态本来就属于"选项"这个概念,
|
||||
* 不属于某一个页面。
|
||||
*/
|
||||
export function ComposerChip({
|
||||
label,
|
||||
active,
|
||||
disabled,
|
||||
onSelect,
|
||||
tone = 'neutral',
|
||||
variant = 'toggle'
|
||||
}: {
|
||||
label: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
tone?: 'neutral' | 'approve' | 'danger';
|
||||
/**
|
||||
* toggle:提问型的多选/单选胶囊 —— 选中才填色,未选中是淡淡的。
|
||||
* action:审批型的"同意/拒绝"—— 它本身就是动作按钮,按语义**一直**填色
|
||||
* (同意=实心绿、拒绝=浅红),与重构前的观感一致。
|
||||
*/
|
||||
variant?: 'toggle' | 'action';
|
||||
}) {
|
||||
const activeCls =
|
||||
tone === 'approve'
|
||||
? 'bg-green-700 text-white border-green-700 hover:bg-green-800'
|
||||
: tone === 'danger'
|
||||
? 'bg-red-700 text-white border-red-700 hover:bg-red-800'
|
||||
: 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800';
|
||||
const idleCls =
|
||||
tone === 'approve'
|
||||
? 'bg-green-50 text-green-800 border-green-200 hover:bg-green-100'
|
||||
: tone === 'danger'
|
||||
? 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100'
|
||||
: 'glass-control text-gray-700 border-gray-300 hover:bg-gray-50';
|
||||
const actionCls =
|
||||
tone === 'approve'
|
||||
? 'bg-green-700 text-white border-green-700 hover:bg-green-800'
|
||||
: 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100';
|
||||
const isAction = variant === 'action';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
aria-pressed={isAction ? undefined : active}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
|
||||
isAction ? actionCls : active ? activeCls : idleCls
|
||||
}`}
|
||||
>
|
||||
{(isAction || active) && tone === 'approve' ? <CheckIcon className="w-3.5 h-3.5" /> : null}
|
||||
{(isAction || active) && tone === 'danger' ? <CloseIcon className="w-3.5 h-3.5" /> : null}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -14,7 +14,8 @@ import {
|
||||
} from '../lib/replyTarget';
|
||||
import * as api from '../api/client';
|
||||
import type { Mail } from '../types';
|
||||
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
|
||||
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
|
||||
import { Composer, ComposerChip } from './Composer';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
@ -754,60 +755,49 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
||||
};
|
||||
// 回答必须非空:空提交会让模型拿到一个什么都没说的结果继续跑。
|
||||
const blank = picked.length === 0 && note.trim().length === 0;
|
||||
const hint =
|
||||
options.length === 0
|
||||
? '这题没有预设选项,请直接填写回答:'
|
||||
: multi
|
||||
? '可多选,也可补充说明:'
|
||||
: '请选择一项,也可补充说明:';
|
||||
|
||||
// 形状(输入区/动作行/提示)与另两处回信完全同一套;差异只有"选项胶囊"
|
||||
// 这一段,走 Composer 的 header 条件渲染(见 Composer 顶部的说明)。
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||
{staleBanner}
|
||||
<div className="text-2xs text-gray-500 mb-2">
|
||||
{options.length === 0
|
||||
? '这题没有预设选项,请直接填写回答:'
|
||||
: multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'}
|
||||
</div>
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => {
|
||||
const on = picked.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => toggle(opt)}
|
||||
disabled={busy}
|
||||
aria-pressed={on}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
|
||||
on
|
||||
? 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800'
|
||||
: 'glass-control text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{on && <CheckIcon className="w-3.5 h-3.5" />}
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
<Composer
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
onChange={setNote}
|
||||
rows={options.length === 0 ? 3 : 2}
|
||||
placeholder={options.length === 0 ? '你的回答(必填)' : '补充说明(可选)'}
|
||||
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 resize-y focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
header={
|
||||
<div>
|
||||
<div className="text-2xs text-gray-500 mb-2">{hint}</div>
|
||||
{options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => (
|
||||
<ComposerChip
|
||||
key={opt}
|
||||
label={opt}
|
||||
active={picked.includes(opt)}
|
||||
disabled={busy}
|
||||
onSelect={() => toggle(opt)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
submit={{
|
||||
label: '提交回答',
|
||||
busy,
|
||||
disabled: blank,
|
||||
onClick: () => submit(picked.join('\n'), note.trim())
|
||||
}}
|
||||
hint={blank ? '请先选择或填写回答' : undefined}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submit(picked.join('\n'), note.trim())}
|
||||
disabled={busy || blank}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-blue-700 text-white hover:bg-blue-800 disabled:opacity-40"
|
||||
>
|
||||
提交回答
|
||||
</button>
|
||||
{blank && (
|
||||
<span className="text-2xs text-gray-400">请先选择或填写回答</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -816,35 +806,32 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
||||
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
|
||||
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
|
||||
|
||||
// 同样是那个 Composer:这里只换三件事 —— 备注是**单行**、选项胶囊按语义上色、
|
||||
// 点胶囊即提交(审批只有"批准/拒绝"两个动作,不需要再按一次提交)。
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||
{staleBanner}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => submit(opt, note)}
|
||||
disabled={busy}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
||||
isApprove(opt)
|
||||
? 'bg-green-700 text-white hover:bg-green-800'
|
||||
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
||||
}`}
|
||||
>
|
||||
{isApprove(opt) ? (
|
||||
<CheckIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
<Composer
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
onChange={setNote}
|
||||
singleLine
|
||||
placeholder="备注(可选)"
|
||||
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
header={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => (
|
||||
<ComposerChip
|
||||
key={opt}
|
||||
label={opt}
|
||||
variant="action"
|
||||
tone={isApprove(opt) ? 'approve' : 'danger'}
|
||||
disabled={busy}
|
||||
onSelect={() => submit(opt, note)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
// 审批型**不给 submit**:点选项即提交(多一个提交口会以 options[0] 兜底,
|
||||
// 等于点一下就批准)。备注走上面的输入框。
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -993,13 +980,22 @@ function ReplyBar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
{/* 输入区走共用的 Composer(roomy 档:回信要写作空间 + 等宽字体)。
|
||||
发送动作也并入它的动作行,附属内容(附件、预算、回复全部)走 footerExtra ——
|
||||
形状只有一处定义,差异是数据。 */}
|
||||
<Composer
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
onChange={setBody}
|
||||
placeholder="回复内容(Markdown)"
|
||||
// 窄屏是「点球展开」进来的,焦点直接落到输入框,少一次点击
|
||||
density="roomy"
|
||||
autoFocus={narrow}
|
||||
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
error={error}
|
||||
submit={{
|
||||
label: '发送',
|
||||
busy,
|
||||
disabled: !body.trim(),
|
||||
onClick: send
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
|
||||
@ -1068,13 +1064,8 @@ function ReplyBar({
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={busy || !body.trim()}
|
||||
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? '发送中' : '发送'}
|
||||
</button>
|
||||
{/* 发送按钮已并入上面 Composer 的动作行:同一个 UI,不再各写一个。
|
||||
这里只留「清空」这类不属于"提交"的动作。 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -96,7 +96,12 @@ export default function Sidebar() {
|
||||
return (
|
||||
<button
|
||||
key={short}
|
||||
onClick={() => setViewMode(target || commTab || modes[0])}
|
||||
onClick={() =>
|
||||
// ★ 只有「通信」是回上次的子页签;日历/联系人必须去**自己**那一项。
|
||||
// 原先写成 `target || commTab || modes[0]`,而日历/联系人没有 target
|
||||
// ⇒ 点它们会跳到 commTab(收件箱)—— 用户报的"点击无法翻页"就是这个。
|
||||
setViewMode(target ?? (isComm ? commTab : modes[0]))
|
||||
}
|
||||
title={title}
|
||||
data-active={active}
|
||||
className="nav-item relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5"
|
||||
|
||||
62
client/electron/test/components/Sidebar.test.tsx
Normal file
62
client/electron/test/components/Sidebar.test.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import Sidebar from '../../src/components/Sidebar';
|
||||
import { useUIStore } from '../../src/stores/uiStore';
|
||||
import { useAuthStore } from '../../src/stores/authStore';
|
||||
import { useMailStore } from '../../src/stores/mailStore';
|
||||
import { useContactStore } from '../../src/stores/contactStore';
|
||||
|
||||
/*
|
||||
* 侧栏导航**点了要去对地方**。
|
||||
*
|
||||
* 这个判据是补写的:用户报「侧边导航栏完全不可用,点击无法翻页」——
|
||||
* 根因是「通信」合并成一项后我写成 `setViewMode(target || commTab || modes[0])`,
|
||||
* 而日历/联系人没有 `target` ⇒ 点它们会跳到 `commTab`(收件箱)。
|
||||
*
|
||||
* 我当时的验证只看了 DOM 结构与样式(导航项数、徽标、圆角、玻璃),
|
||||
* **一次都没点过**,所以这个 bug 一路到了线上。结构类判据必须配一条"点它"的判据。
|
||||
*/
|
||||
describe('侧栏导航点击目标', () => {
|
||||
let view: ReturnType<typeof render>;
|
||||
beforeEach(() => {
|
||||
useUIStore.getState().reset();
|
||||
useAuthStore.setState({ user: { username: 'gui-lab', role: 'admin' } as never });
|
||||
useMailStore.setState({ inbox: [] });
|
||||
useContactStore.setState({ contacts: [] });
|
||||
view = render(<Sidebar />);
|
||||
});
|
||||
|
||||
// 按**可见文字**找按钮(不用 getByTitle:title 属性在这个环境里查不到,
|
||||
// 而按钮文字就是用户点的东西 —— 判据要贴住用户实际看到的那一层)
|
||||
const click = (label: string) => {
|
||||
const btn = [...document.querySelectorAll('button')].find(b =>
|
||||
(b.textContent || '').trim() === label
|
||||
);
|
||||
if (!btn) throw new Error(`找不到导航项:${label}(现有:${[...document.querySelectorAll('button')].map(b => b.textContent?.trim()).join('/')})`);
|
||||
fireEvent.click(btn);
|
||||
return useUIStore.getState().viewMode;
|
||||
};
|
||||
|
||||
it('点「日历」去日历(不是回通信)', () => {
|
||||
expect(click('日历')).toBe('calendar');
|
||||
});
|
||||
|
||||
it('点「联系」去联系人', () => {
|
||||
expect(click('联系')).toBe('contacts');
|
||||
});
|
||||
|
||||
it('点「通信」去通信上次用的子页签', () => {
|
||||
// ★ 改 store 之后必须**重新渲染**再点:否则组件的点击闭包里还是旧的 commTab
|
||||
// (我第一版没 rerender,拿到 'inbox',差点误判成代码有问题)。
|
||||
// 这里也说明了另一件事:内部页签渲染在 App 里而不是 Sidebar 里,
|
||||
// 所以 Sidebar 的单测无法通过"点页签"来设置它。
|
||||
useUIStore.getState().setViewMode('sent');
|
||||
view.rerender(<Sidebar />);
|
||||
expect(click('通信')).toBe('sent');
|
||||
});
|
||||
|
||||
it('★ 反向对照:从日历点「通信」不会停在日历', () => {
|
||||
useUIStore.getState().setViewMode('calendar');
|
||||
expect(click('通信')).toBe('inbox');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user