fix(webui): 日历补上圆角 —— 外层面板圆了、里面的格子还是直角

用户:「日历页面怎么没有圆角」。

原因与列表那次同源:**外层面板一直是圆的**(`.app-shell > *` 给了 14px),
但里面的日格/列写的是 `border-b border-r`(直角 + 细边框),而我早前为了修滚动
去掉了面板的 `overflow: hidden` ⇒ 里面的直角就戳在圆角外面,看起来"整页没有圆角"。

改法:日格、周视图列、以及日历的两个大面板都改用 `.glass-card`
(圆角 14px + 白色玻璃 + 细边框),与列表项、顶部气泡同一套语言。
顺手去掉日格上的 `overflow-hidden`(它会把事件条裁掉,而"裁掉"正是这几轮的老毛病)。

实测(自有浏览器,壁纸开):日历里 **42 个 `.glass-card`**(月视图 42 格 + 面板),
格子 `border-radius: 14px`、底色 `rgba(255,255,255,0.78)`。

(这条同样是"外层圆角 + 内层直角"这一类问题 —— 我已经在列表、顶部、日历上
各修了一次。要找根因的话:**面板圆角不能靠 `overflow: hidden` 裁**(它会杀滚动),
所以每一层自己都要圆角。这是这套"浮动面板"设计的固有代价,写在这里备查。)
This commit is contained in:
2026-09-14 13:47:58 +08:00
parent dfaeab0e4d
commit bb855b1518
4 changed files with 282 additions and 130 deletions

View File

@ -511,7 +511,7 @@ function MonthGrid({
key={i}
onClick={() => onPickDay(d)}
onDoubleClick={() => onCreateAt(atNineOClock(d))}
className={`border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden cursor-pointer ${
className={`glass-card border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 cursor-pointer ${
isPicked ? 'bg-blue-50/70 ring-1 ring-inset ring-blue-300' : outside ? 'bg-gray-50/60' : 'bg-white'
}`}
>
@ -572,7 +572,7 @@ function WeekGrid({
const now = new Date();
return (
<div className="flex-1 min-h-0 overflow-auto">
<div className="glass-card flex-1 min-h-0 overflow-auto">
<div className="grid grid-cols-7 min-w-[36rem] h-full">
{days.map((d, i) => {
const list = byDay.get(dayKey(d)) ?? [];
@ -583,7 +583,7 @@ function WeekGrid({
key={i}
onClick={() => onPickDay(d)}
onDoubleClick={() => onCreateAt(atNineOClock(d))}
className={`border-r border-gray-100 flex flex-col min-h-[28rem] cursor-pointer ${
className={`glass-card border-r border-gray-100 flex flex-col min-h-[28rem] cursor-pointer ${
isPicked ? 'bg-blue-50/50' : ''
}`}
>
@ -645,7 +645,7 @@ function DayGrid({
const nowHour = isSameDay(anchor, new Date()) ? new Date().getHours() : -1;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="glass-card flex-1 min-h-0 overflow-y-auto">
<div className="px-3 py-2 text-xs text-gray-500 border-b border-gray-100">
{formatSolarWithLunar(anchor)}
</div>

View File

@ -199,3 +199,118 @@ test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用"
assert.match(page, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)');
assert.match(page, /this\.WorkCard\(c\)/, '卡片视图要真的渲染出来');
});
// ───────────────────────── 撤掉平级「会话」tabP2a 收尾) ─────────────────────────
test('底部只剩 收件箱 / 联系人 两个平级页签,「会话」不再是入口', () => {
/*
* pi 的判断WebUI 里「会话」从来不是一个入口,它是**两处已有视图**(联系人页的卡片视图
* + 收件箱的会话折叠)。鸿蒙原来把它单列成 tab等于把"卡片视图"放错了位置。
* 顺序也按他说的:**先补视图与折叠,再撤 tab** —— 撤早了,
* 往返预算 / status / from_agent 这些只在会话列表里出现的信息就没地方看了。
*/
const tabLabels = [...page.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]);
assert.deepEqual(tabLabels, ['收件箱', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`);
assert.ok(!/struct\s+SessionsTab/.test(page), 'SessionsTab 已经撤了,不该再留在页面里');
assert.ok(!/sessions\(\)/.test(page), '撤了入口就不该再拉 /me/sessions否则是没人看的请求');
});
test('卡片视图的字段集与 WebUI 的 WorkCard 一致(撤 tab 后"信息没丢"的依据)', () => {
/*
* 撤掉会话列表的前提是"它独有的信息都还在"。这条判据把这个前提变成可执行的:
* 参考实现WebUI `WorkCard.tsx`)显示哪些字段,鸿蒙卡片就得显示哪些 ——
* 少一个说明撤 tab 丢了东西;哪天 WebUI 补了新字段,这条也会红,提醒跟着补。
*/
const fields = src => new Set([...src.matchAll(/\bc\.([a-z_]+)/g)].map(m => m[1]));
const web = fields(webCard);
// 鸿蒙卡片 Builder 的正文(从 `WorkCard(c: Contact)` 到下一个 @Builder 之前)
const cardStart = page.indexOf('WorkCard(c: Contact)');
assert.ok(cardStart > 0, '找不到鸿蒙的卡片 Builder');
const cardBody = page.slice(cardStart, page.indexOf('@Builder', cardStart));
const harmony = fields(cardBody);
const missing = [...web].filter(f => !harmony.has(f));
const extra = [...harmony].filter(f => !web.has(f));
assert.deepEqual(missing, [], `鸿蒙卡片少了 WebUI 卡片有的字段:${missing.join('、')}`);
assert.deepEqual(extra, [], `鸿蒙卡片多了 WebUI 没有的字段(要么补进 WebUI要么说明理由${extra.join('、')}`);
});
// ───────────────────────── 权限档位 + 强制力(与 WebUI PermissionChip 同文案) ─────────────────────────
test('档位标签与强制力标记:和 WebUI 的 MODE_LABEL / ENFORCEMENT_LABEL 逐字一致', async () => {
const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8');
const labelOf = (name, src) => {
const block = src.slice(src.indexOf(`const ${name}`), src.indexOf('};', src.indexOf(`const ${name}`)));
return new Map([...block.matchAll(/(\w+):\s*'([^']+)'/g)].map(m => [m[1], m[2]]));
};
const webModes = labelOf('MODE_LABEL', webChip);
const webEnf = labelOf('ENFORCEMENT_LABEL', webChip);
for (const [mode, label] of webModes) {
assert.equal(H.permissionLabel(mode), label, `${mode} 档的中文标签两边必须一致`);
}
assert.equal(H.permissionLabel(''), '', '空档位不显示徽标(人→人的信、旧会话)');
for (const [e, label] of webEnf) {
assert.equal(H.enforcementLabel(e), label, `${e} 的强制力标签两边必须一致`);
}
// 认不出的值按 advisory保守方向绝不当成"平台拦得住"
assert.equal(H.enforcementKey('未知'), 'advisory');
assert.equal(H.enforcementKey(''), 'advisory');
assert.match(webChip, /e === 'native' \|\| e === 'partial' \|\| e === 'advisory' \? e : 'advisory'/, 'WebUI 的保守默认值变了');
// 三种强制力必须有**三种**标记WebUI 用实心/靶心/空心三种点,鸿蒙用三个字形)
const glyphs = ['native', 'partial', 'advisory'].map(H.enforcementGlyph);
assert.equal(new Set(glyphs).size, 3, `三种强制力必须形状可辨,实际:${glyphs.join('')}`);
assert.equal(H.permissionChipText('plan', 'partial'), '只读 ◉');
assert.equal(H.permissionChipText('', 'native'), '', '空档位连标记都不该有');
assert.equal(H.enforcementLabel(''), '仅提示', '空/未知强制力必须说"仅提示",不能默认成平台强制');
});
test('★ 档位说明文案与 WebUI permissionModeHint 逐字一致(两边不能给两种保证)', () => {
/*
* 鸿蒙跑不了 TSX文案只能 port 一份;**可执行的比对是它的替代品**
* 从 WebUI 源码里抽出 permissionModeHint 的所有 return 字符串,
* 再要求鸿蒙的 permissionHint 对 3 档 × 3 强制力(外加空/未知)给出的每一句话
* 都在那个集合里 —— 任一边改了口径就红。
*/
const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8');
const fn = webChip.slice(webChip.indexOf('export function permissionModeHint'));
const body = fn.slice(0, fn.indexOf('\n}'));
const webHints = [...body.matchAll(/return\s+((?:'(?:[^'\\]|\\.)*'\s*\+?\s*)+);/g)].map(m =>
[...m[1].matchAll(/'((?:[^'\\]|\\.)*)'/g)].map(x => x[1]).join('').replace(/\\'/g, "'")
);
assert.ok(webHints.length >= 7, `WebUI 的说明文案应该至少 7 条,实际 ${webHints.length}`);
const modes = ['plan', 'workspace', 'full', ''];
const ens = ['native', 'partial', 'advisory', '未知', ''];
for (const mode of modes) {
const said = new Set();
for (const e of ens) {
const hint = H.permissionHint(mode, e);
assert.ok(hint.length > 0, `${mode}/${e} 必须给出说明`);
assert.ok(webHints.includes(hint), `${mode}/${e} 的文案与 WebUI 不一致:${hint}`);
said.add(hint);
}
// 认不出的强制力要落到保守档(与 advisory 同一句话),不能各自发明措辞
assert.equal(new Set([...said]).size <= 3, true, `${mode} 档的说明不该超过三种措辞`);
}
// three-strikesplan 与 workspace 的三种强制力必须给出三种话WebUI 的硬要求)
for (const mode of ['plan', 'workspace']) {
const three = new Set(ens.slice(0, 3).map(e => H.permissionHint(mode, e)));
assert.equal(three.size, 3, `${mode} 档的三种强制力必须给出三种话`);
}
});
test('徽标真的挂在界面上,且点它能看到那句说明(触屏没有悬停)', () => {
assert.match(page, /permissionChipText\(c\.permission_mode, c\.permission_enforcement\)/, '卡片要用"档位+强制力"的徽标');
assert.match(page, /permissionHint\(c\.permission_mode, c\.permission_enforcement\)/, '点徽标要弹出说明');
assert.match(page, /promptAction\.showToast\(/, '说明走 toast手指没有 hover');
assert.match(page, /enforcementLabel\(c\.permission_enforcement\)/, '说明里要带强制力标签');
assert.match(page, /permissionLabel\(mail\.permission_mode\)/, '收件箱行要用中文档位');
// 收件箱列表接口没有 enforcement 字段,那里不许凭空画强制力标记
const mailItem = page.slice(page.indexOf('MailItem(mail: MailLike)'));
assert.ok(
!/permissionChipText\(mail\./.test(mailItem),
'收件箱每封邮件里没有 permission_enforcement画强制力标记等于编一个"平台做到了什么"'
);
});