qq-bot-roost 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/api.js +38 -2
- package/src/bot.js +3 -0
- package/src/config.js +18 -0
- package/src/message.js +3 -0
- package/src/web/server.js +18 -0
- package/web/app.js +243 -0
- package/web/index.html +138 -51
- package/web/style.css +477 -0
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
<img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
|
|
10
10
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
|
|
11
|
-
<img src="https://img.shields.io/badge/tests-
|
|
11
|
+
<img src="https://img.shields.io/badge/tests-826%20passed-brightgreen" alt="测试 826 项">
|
|
12
12
|
<img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
|
|
13
|
-
<img src="https://img.shields.io/badge/version-1.0.
|
|
13
|
+
<img src="https://img.shields.io/badge/version-1.0.10-blueviolet" alt="版本 1.0.10">
|
|
14
14
|
<img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
|
|
15
15
|
<a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
|
|
16
16
|
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -147,6 +147,8 @@ export class QQApi {
|
|
|
147
147
|
this.seqMap = new Map();
|
|
148
148
|
// 群管内邀开关,由 BotRuntime 按配置同步;默认关
|
|
149
149
|
this.groupInvited = false;
|
|
150
|
+
// 「发图带空格」兼容开关,由 BotRuntime 按 cfg.media.placeholderSpace 同步;默认关
|
|
151
|
+
this.mediaPlaceholderSpace = false;
|
|
150
152
|
// 群角色查询缓存:groupOpenid -> { at, value }。bot_state 只有 30 QPM
|
|
151
153
|
this.roleCache = new Map();
|
|
152
154
|
/**
|
|
@@ -564,11 +566,15 @@ export class QQApi {
|
|
|
564
566
|
const up = await this.uploadMedia(scene, target, { fileType, url, fileData });
|
|
565
567
|
if (!up?.file_info) throw new Error('上传成功但未返回 file_info');
|
|
566
568
|
/**
|
|
567
|
-
*
|
|
569
|
+
* **不要在这里传 content。**
|
|
568
570
|
*
|
|
569
571
|
* 官方字段表:`msg_type=7` 时生效的是 `media`,`content` 只在
|
|
570
572
|
* `msg_type=0` 时是全文。以前这里填了一个空格占位,客户端会把
|
|
571
573
|
* 它当成正文,渲染出一块空的文字区(图被挤到一侧)。
|
|
574
|
+
*
|
|
575
|
+
* 那个空格现在做成面板「配置 → 发图兼容」的可选开关(默认关):
|
|
576
|
+
* 打开后由 `#payload()` 在最终报文里补,这里依旧不带 content,
|
|
577
|
+
* 免得开关关着时又冒出空格、开着时补两遍。
|
|
572
578
|
*/
|
|
573
579
|
const opts = {
|
|
574
580
|
msgType: 7,
|
|
@@ -632,6 +638,19 @@ export class QQApi {
|
|
|
632
638
|
return this.groupInvited;
|
|
633
639
|
}
|
|
634
640
|
|
|
641
|
+
/**
|
|
642
|
+
* 「发图带空格」开关。由 BotRuntime 按 cfg.media.placeholderSpace 初始化,
|
|
643
|
+
* 面板改动时由 /api/config 同步进来,立即生效、不用重启。
|
|
644
|
+
*
|
|
645
|
+
* 只认布尔 true:`"false"` / `0` / `undefined` 一律当关。
|
|
646
|
+
* 面板配置走 HTTP,字符串 "false" 按真值判断会被读成「开」——
|
|
647
|
+
* security.trust.enforce 踩过这个坑,这里不重犯。
|
|
648
|
+
*/
|
|
649
|
+
setMediaPlaceholderSpace(v) {
|
|
650
|
+
this.mediaPlaceholderSpace = v === true;
|
|
651
|
+
return this.mediaPlaceholderSpace;
|
|
652
|
+
}
|
|
653
|
+
|
|
635
654
|
/** 内邀接口的统一闸门。没开门就抛错,别把请求发出去刷一屏 11253 */
|
|
636
655
|
#needInvite() {
|
|
637
656
|
if (!this.groupInvited) {
|
|
@@ -1260,7 +1279,24 @@ export class QQApi {
|
|
|
1260
1279
|
}
|
|
1261
1280
|
|
|
1262
1281
|
#payload(opts) {
|
|
1263
|
-
|
|
1282
|
+
const payload = buildSendPayload(opts, (id) => this.#nextSeq(id));
|
|
1283
|
+
/**
|
|
1284
|
+
* 「发图带空格」兼容开关(默认关,面板「配置 → 发图兼容」)。
|
|
1285
|
+
*
|
|
1286
|
+
* 放在最终报文这一层,是为了**一处覆盖所有发图路径**:
|
|
1287
|
+
* msg.replyImage / replyImageBuffer、api.sendMedia、插件拿到的
|
|
1288
|
+
* ctx.api.sendMedia、以及没有调度器时的 api.sendGroup 兜底,最后都过这里。
|
|
1289
|
+
* 放在 outbound 或 message 层都会漏,还会污染投递账本的正文摘要
|
|
1290
|
+
* (账本把纯空格当「没有正文」,测试也断言富媒体不带 content)。
|
|
1291
|
+
*
|
|
1292
|
+
* 只补「没有正文的富媒体」:
|
|
1293
|
+
* · msg_type=7 且 content 为空 —— 老版就是在这里塞 ' '
|
|
1294
|
+
* · 有 text 的图文混排不动,markdown(2) / ark(3) / 卡片(8) 也不动
|
|
1295
|
+
*/
|
|
1296
|
+
if (this.mediaPlaceholderSpace && payload.msg_type === 7 && !payload.content) {
|
|
1297
|
+
payload.content = ' ';
|
|
1298
|
+
}
|
|
1299
|
+
return payload;
|
|
1264
1300
|
}
|
|
1265
1301
|
|
|
1266
1302
|
/**
|
package/src/bot.js
CHANGED
|
@@ -200,6 +200,9 @@ export class BotRuntime {
|
|
|
200
200
|
// 群管内邀开关:api 层有自己的副本,配置变了由面板接口同步过来。
|
|
201
201
|
// mock 的 api 也实现了同名方法,一起同步,方便本地验证插件的群管逻辑
|
|
202
202
|
this.api.setGroupInvited?.(cfg.groupManage?.invited);
|
|
203
|
+
// 发图带空格:只影响真实 QQApi 的最终报文;mock api 不拼官方报文、
|
|
204
|
+
// 没实现这个 setter,?. 会直接跳过(演示模式下开关不适用)
|
|
205
|
+
this.api.setMediaPlaceholderSpace?.(cfg.media?.placeholderSpace);
|
|
203
206
|
|
|
204
207
|
this.session = new SessionManager({
|
|
205
208
|
stickyMs: Math.max(0, (cfg.session?.stickySec ?? 180) * 1000),
|
package/src/config.js
CHANGED
|
@@ -335,6 +335,22 @@ const BOT_DEFAULTS = {
|
|
|
335
335
|
groupManage: {
|
|
336
336
|
invited: false,
|
|
337
337
|
},
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 富媒体(图片 / 视频 / 语音 / 文件)发送兼容选项。
|
|
341
|
+
*
|
|
342
|
+
* `placeholderSpace` 默认 false:只发一张图时,msg_type=7 的 content
|
|
343
|
+
* 保持空串,QQ 里是一张干净的图(v1.0.6 起的行为)。
|
|
344
|
+
* 打开后由 `QQApi.#payload()` 给没有正文的富媒体补一个空格,回到
|
|
345
|
+
* v1.0.6 之前的排版:客户端按图文混排渲染,图外面多一块空白文字区。
|
|
346
|
+
* 老客户端 / 旧插件依赖那个占位空格时才需要打开。
|
|
347
|
+
*
|
|
348
|
+
* 官方对非图片类型的 content 会忽略,所以实际只对图片有观感影响;
|
|
349
|
+
* 带说明文字的图片(replyImage 的 text)不受影响。
|
|
350
|
+
*/
|
|
351
|
+
media: {
|
|
352
|
+
placeholderSpace: false,
|
|
353
|
+
},
|
|
338
354
|
};
|
|
339
355
|
|
|
340
356
|
function deepMerge(base, extra) {
|
|
@@ -529,6 +545,8 @@ export async function saveBotConfig(id, cfg) {
|
|
|
529
545
|
gate: cfg.gate ?? { pauseOnStart: false },
|
|
530
546
|
plugins: cfg.plugins,
|
|
531
547
|
groupManage: cfg.groupManage ?? { invited: false },
|
|
548
|
+
// 脏值(字符串 / 数组 / 旧配置里混进来的东西)一律规范成默认关,别污染配置文件
|
|
549
|
+
media: { placeholderSpace: cfg.media?.placeholderSpace === true },
|
|
532
550
|
});
|
|
533
551
|
}
|
|
534
552
|
|
package/src/message.js
CHANGED
|
@@ -848,6 +848,9 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
|
|
|
848
848
|
* 理由是「官方不接受空 content」——那说的是传 `''`,跟不传不是一回事,
|
|
849
849
|
* 结果客户端按图文混排渲染,图外面多出一圈文字区(v1.0.6 修掉)。
|
|
850
850
|
*
|
|
851
|
+
* 面板「配置 → 发图兼容」把这个空格做成了可选开关:打开后由 api 层在
|
|
852
|
+
* 最终报文里补回占位空格(老客户端 / 旧插件需要时),这里依旧不带 content。
|
|
853
|
+
*
|
|
851
854
|
* 发送前会先把攒着的文本 flush 掉,免得「缓冲中的回复」被这条
|
|
852
855
|
* 富媒体消息挤成顺序错乱的两条。
|
|
853
856
|
*/
|
package/src/web/server.js
CHANGED
|
@@ -1605,6 +1605,24 @@ export async function startWebServer({
|
|
|
1605
1605
|
cfg.groupManage = { invited: Boolean(body.groupManage.invited) };
|
|
1606
1606
|
rt.api?.setGroupInvited?.(cfg.groupManage.invited);
|
|
1607
1607
|
}
|
|
1608
|
+
/**
|
|
1609
|
+
* 「发图带空格」兼容开关(默认关)。
|
|
1610
|
+
* 严格只认布尔 true:字符串 "false" 按真值判断会被当成「开」,
|
|
1611
|
+
* 和 security.trust.enforce 踩过的是同一个坑。
|
|
1612
|
+
* 同步给 api 实例,勾选后立即生效、不用重启。
|
|
1613
|
+
*
|
|
1614
|
+
* 还要求「对象 + 布尔字段」齐全才写:面板漏传字段、旧版前端、
|
|
1615
|
+
* 或者 body.media 是 [] / {} 时,不该把已经打开的开关静默关掉。
|
|
1616
|
+
*/
|
|
1617
|
+
if (
|
|
1618
|
+
body.media &&
|
|
1619
|
+
typeof body.media === 'object' &&
|
|
1620
|
+
!Array.isArray(body.media) &&
|
|
1621
|
+
typeof body.media.placeholderSpace === 'boolean'
|
|
1622
|
+
) {
|
|
1623
|
+
cfg.media = { placeholderSpace: body.media.placeholderSpace };
|
|
1624
|
+
rt.api?.setMediaPlaceholderSpace?.(cfg.media.placeholderSpace);
|
|
1625
|
+
}
|
|
1608
1626
|
|
|
1609
1627
|
await saveBotConfig(rt.id, cfg);
|
|
1610
1628
|
log.info('配置已通过面板更新');
|
package/web/app.js
CHANGED
|
@@ -606,6 +606,8 @@ function activateTab(name) {
|
|
|
606
606
|
if (name === 'roles') { loadRoles(); loadPeople(); }
|
|
607
607
|
if (name === 'identity') loadIdentity();
|
|
608
608
|
if (name === 'notify') loadNotify();
|
|
609
|
+
// 纯展示同步:侧栏 / 底栏高亮、面包屑、抽屉收起。不碰任何数据加载。
|
|
610
|
+
syncNav(name);
|
|
609
611
|
}
|
|
610
612
|
|
|
611
613
|
/**
|
|
@@ -1070,6 +1072,8 @@ function fillForm(c) {
|
|
|
1070
1072
|
f.logLevel.value = c.logLevel || 'info';
|
|
1071
1073
|
|
|
1072
1074
|
f.groupInvited.checked = Boolean(c.groupManage?.invited);
|
|
1075
|
+
// 严格只认 true:老配置文件里没这个字段时保持关闭
|
|
1076
|
+
f.mediaPlaceholderSpace.checked = c.media?.placeholderSpace === true;
|
|
1073
1077
|
|
|
1074
1078
|
/**
|
|
1075
1079
|
* 插件来源校验开关。`pubkeySet: false` 时必须说清楚「开了也没用」——
|
|
@@ -1405,6 +1409,36 @@ document.addEventListener('click', async (e) => {
|
|
|
1405
1409
|
}
|
|
1406
1410
|
});
|
|
1407
1411
|
|
|
1412
|
+
/* ---------------- 发图兼容:勾选即生效 ---------------- */
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* 「保存配置」按钮在表单顶部/底部,离这块设置很远,勾完还得滚过去点一下才生效。
|
|
1416
|
+
* 这里跟 `autoSaveIntents` 一个套路:change 时局部 PUT 只提交 media 一个字段,
|
|
1417
|
+
* 后端 `/api/config` 是合并语义,其它配置项不会被这次请求碰。
|
|
1418
|
+
* 不需要防抖——勾一下就是一次,且不会像 intents 那样触发重连。
|
|
1419
|
+
*/
|
|
1420
|
+
$('#mediaPlaceholderSpace').addEventListener('change', async (e) => {
|
|
1421
|
+
const on = e.target.checked;
|
|
1422
|
+
const tip = $('#mediaSpaceMsg');
|
|
1423
|
+
if (tip) tip.textContent = '正在应用…';
|
|
1424
|
+
try {
|
|
1425
|
+
await api('/api/config', {
|
|
1426
|
+
method: 'PUT',
|
|
1427
|
+
body: JSON.stringify({ media: { placeholderSpace: on } }),
|
|
1428
|
+
});
|
|
1429
|
+
if (tip) {
|
|
1430
|
+
tip.textContent = on ? '已生效:发图会带空格' : '已生效:恢复干净图';
|
|
1431
|
+
setTimeout(() => {
|
|
1432
|
+
if (tip.textContent.startsWith('已生效')) tip.textContent = '';
|
|
1433
|
+
}, 4000);
|
|
1434
|
+
}
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
// 没保存成功就把勾选退回去,别让用户以为已经开了
|
|
1437
|
+
e.target.checked = !on;
|
|
1438
|
+
if (tip) tip.textContent = '应用失败: ' + err.message;
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1408
1442
|
$('#cfgForm').addEventListener('submit', async (e) => {
|
|
1409
1443
|
e.preventDefault();
|
|
1410
1444
|
const f = e.target;
|
|
@@ -1444,6 +1478,10 @@ $('#cfgForm').addEventListener('submit', async (e) => {
|
|
|
1444
1478
|
groupManage: {
|
|
1445
1479
|
invited: f.groupInvited.checked,
|
|
1446
1480
|
},
|
|
1481
|
+
// 发图兼容:和 groupManage 一样是每台机器人一份,勾选后立即生效
|
|
1482
|
+
media: {
|
|
1483
|
+
placeholderSpace: f.mediaPlaceholderSpace.checked,
|
|
1484
|
+
},
|
|
1447
1485
|
// 全局设置(config/bot.json),和 webui 一样不写进机器人配置
|
|
1448
1486
|
security: {
|
|
1449
1487
|
trust: { enforce: f.trustEnforce.checked },
|
|
@@ -4447,3 +4485,208 @@ function connectStream() {
|
|
|
4447
4485
|
// 机器人确定后再刷一次:上面那次是抢时间,这次才是准数据
|
|
4448
4486
|
if (initial) activateTab(initial);
|
|
4449
4487
|
})();
|
|
4488
|
+
|
|
4489
|
+
/* ============================================================
|
|
4490
|
+
响应式导航外观(2026-11-06 重构新增)
|
|
4491
|
+
------------------------------------------------------------
|
|
4492
|
+
这一整段只做「壳」:高亮、面包屑、抽屉开合、表格标签、theme-color。
|
|
4493
|
+
不调用任何数据加载函数,不改任何 API、轮询或 iframe 逻辑。
|
|
4494
|
+
============================================================ */
|
|
4495
|
+
|
|
4496
|
+
/**
|
|
4497
|
+
* 页签切换后同步各处导航状态。
|
|
4498
|
+
* 由 activateTab() 末尾调用;切机器人 / hashchange 走的也是 activateTab,
|
|
4499
|
+
* 所以不需要额外注册第二套监听。
|
|
4500
|
+
*/
|
|
4501
|
+
function syncNav(name) {
|
|
4502
|
+
const nav = $('#mainNav');
|
|
4503
|
+
if (!nav) return;
|
|
4504
|
+
const btn = $$('[data-tab]').find(
|
|
4505
|
+
(b) => b.dataset.tab === name && b.classList.contains('tab-item'),
|
|
4506
|
+
);
|
|
4507
|
+
const crumb = $('#navCrumb');
|
|
4508
|
+
if (crumb) {
|
|
4509
|
+
crumb.textContent = btn ? btn.textContent.trim()
|
|
4510
|
+
: (name.startsWith('page:') ? '插件页面' : '概览');
|
|
4511
|
+
}
|
|
4512
|
+
// 停在非高频页时,底栏「更多」给个提示态,免得用户以为没有当前页
|
|
4513
|
+
const more = $('#navMore');
|
|
4514
|
+
if (more) more.classList.toggle('has-active', Boolean(btn) && !btn.hasAttribute('data-primary'));
|
|
4515
|
+
// 切到某个页签时把它所在的折叠组展开,否则用户看不到自己选中了哪一页
|
|
4516
|
+
const group = btn?.closest('.tab-group');
|
|
4517
|
+
if (group?.classList.contains('group-collapsed')) setGroupCollapsed(group, false);
|
|
4518
|
+
// 选完页就把抽屉 / 平板展开态收起来
|
|
4519
|
+
nav.classList.remove('drawer-open', 'expanded');
|
|
4520
|
+
const toggle = $('#navToggle');
|
|
4521
|
+
if (toggle) toggle.setAttribute('aria-expanded', 'false');
|
|
4522
|
+
if (more) more.setAttribute('aria-expanded', 'false');
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
/* 侧栏偏好:纯外观状态,存 localStorage,不参与任何业务数据 */
|
|
4526
|
+
const NAV_COMPACT_KEY = 'roost_nav_compact';
|
|
4527
|
+
const NAV_GROUPS_KEY = 'roost_nav_groups';
|
|
4528
|
+
|
|
4529
|
+
/** 折叠 / 展开一个分组,并记住选择 */
|
|
4530
|
+
function setGroupCollapsed(group, on) {
|
|
4531
|
+
if (!group) return;
|
|
4532
|
+
group.classList.toggle('group-collapsed', on);
|
|
4533
|
+
const key = group.dataset.group;
|
|
4534
|
+
if (!key) return;
|
|
4535
|
+
let list = [];
|
|
4536
|
+
try { list = JSON.parse(localStorage.getItem(NAV_GROUPS_KEY) || '[]'); } catch {}
|
|
4537
|
+
if (!Array.isArray(list)) list = [];
|
|
4538
|
+
const next = on ? [...new Set([...list, key])] : list.filter((x) => x !== key);
|
|
4539
|
+
try { localStorage.setItem(NAV_GROUPS_KEY, JSON.stringify(next)); } catch {}
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
/** 恢复上次的收起 / 折叠状态;当前页所在组强制展开 */
|
|
4543
|
+
function applyNavPrefs() {
|
|
4544
|
+
try {
|
|
4545
|
+
if (localStorage.getItem(NAV_COMPACT_KEY) === '1') {
|
|
4546
|
+
document.body.classList.add('nav-compact');
|
|
4547
|
+
}
|
|
4548
|
+
const list = JSON.parse(localStorage.getItem(NAV_GROUPS_KEY) || '[]');
|
|
4549
|
+
if (Array.isArray(list)) {
|
|
4550
|
+
$$('.app-nav .tab-group').forEach((g) => {
|
|
4551
|
+
if (list.includes(g.dataset.group)) g.classList.add('group-collapsed');
|
|
4552
|
+
});
|
|
4553
|
+
}
|
|
4554
|
+
} catch {}
|
|
4555
|
+
const active = $('.app-nav .tab-item.active')?.closest('.tab-group');
|
|
4556
|
+
if (active) setGroupCollapsed(active, false);
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
/**
|
|
4560
|
+
* 给表格单元格补 data-label,供窄屏卡片化的 td::before 使用。
|
|
4561
|
+
*
|
|
4562
|
+
* 12 张表的行几乎全是 JS 动态渲染的(app.js 里 80 多处 <td 模板),
|
|
4563
|
+
* 逐处改模板既伤业务代码又容易漏。这里用 MutationObserver 在 tbody
|
|
4564
|
+
* 内容变化时按 thead 的列序自动贴标签,纯展示、零业务侵入。
|
|
4565
|
+
* 空状态行(colspan)不贴,避免卡片上出现一个没有值的字段。
|
|
4566
|
+
*/
|
|
4567
|
+
function initTableLabels() {
|
|
4568
|
+
const labelsFor = (table) =>
|
|
4569
|
+
[...table.querySelectorAll('thead th')].map((th) => th.textContent.trim());
|
|
4570
|
+
|
|
4571
|
+
const decorate = (tbody) => {
|
|
4572
|
+
const table = tbody.closest('table');
|
|
4573
|
+
if (!table) return;
|
|
4574
|
+
const labels = labelsFor(table);
|
|
4575
|
+
for (const tr of tbody.rows) {
|
|
4576
|
+
Array.from(tr.cells).forEach((td, i) => {
|
|
4577
|
+
if (td.hasAttribute('colspan') || !labels[i]) return;
|
|
4578
|
+
if (!td.dataset.label) td.dataset.label = labels[i];
|
|
4579
|
+
});
|
|
4580
|
+
}
|
|
4581
|
+
};
|
|
4582
|
+
|
|
4583
|
+
const bodies = $$('table tbody');
|
|
4584
|
+
bodies.forEach(decorate);
|
|
4585
|
+
if (typeof MutationObserver === 'undefined') return;
|
|
4586
|
+
|
|
4587
|
+
const mo = new MutationObserver((records) => {
|
|
4588
|
+
for (const r of records) {
|
|
4589
|
+
const t = r.target;
|
|
4590
|
+
if (!t) continue;
|
|
4591
|
+
if (t.tagName === 'TBODY') decorate(t);
|
|
4592
|
+
else if (t.closest) {
|
|
4593
|
+
const tb = t.closest('tbody');
|
|
4594
|
+
if (tb) decorate(tb);
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
});
|
|
4598
|
+
// subtree: 业务若往已有 <tr> 里追加 <td>(不是整块 innerHTML 替换)也要补标签
|
|
4599
|
+
bodies.forEach((tb) => mo.observe(tb, { childList: true, subtree: true }));
|
|
4600
|
+
}
|
|
4601
|
+
|
|
4602
|
+
/** 手机浏览器地址栏跟随深浅色,纯展示,不碰 setTheme() */
|
|
4603
|
+
function initThemeColor() {
|
|
4604
|
+
const meta = document.querySelector('meta[name="theme-color"]');
|
|
4605
|
+
if (!meta) return;
|
|
4606
|
+
const apply = () => {
|
|
4607
|
+
meta.setAttribute(
|
|
4608
|
+
'content',
|
|
4609
|
+
document.documentElement.dataset.theme === 'dark' ? '#19120e' : '#f8f5f1',
|
|
4610
|
+
);
|
|
4611
|
+
};
|
|
4612
|
+
apply();
|
|
4613
|
+
if (typeof MutationObserver === 'undefined') return;
|
|
4614
|
+
new MutationObserver(apply).observe(document.documentElement, {
|
|
4615
|
+
attributes: true,
|
|
4616
|
+
attributeFilter: ['data-theme'],
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4620
|
+
(function initNavChrome() {
|
|
4621
|
+
const nav = $('#mainNav');
|
|
4622
|
+
if (!nav) return;
|
|
4623
|
+
const toggle = $('#navToggle');
|
|
4624
|
+
const more = $('#navMore');
|
|
4625
|
+
const close = $('#navClose');
|
|
4626
|
+
|
|
4627
|
+
const setOpen = (on) => {
|
|
4628
|
+
// 只挂当前档位对应的 class:同时挂两个,用户拖动窗口跨断点时会残留另一个形态
|
|
4629
|
+
const isPhone = window.matchMedia('(max-width: 720px)').matches;
|
|
4630
|
+
nav.classList.toggle('drawer-open', on && isPhone);
|
|
4631
|
+
nav.classList.toggle('expanded', on && !isPhone);
|
|
4632
|
+
if (toggle) toggle.setAttribute('aria-expanded', String(on));
|
|
4633
|
+
if (more) more.setAttribute('aria-expanded', String(on));
|
|
4634
|
+
};
|
|
4635
|
+
|
|
4636
|
+
toggle?.addEventListener('click', (e) => {
|
|
4637
|
+
e.stopPropagation();
|
|
4638
|
+
if (window.matchMedia('(max-width: 720px)').matches) {
|
|
4639
|
+
// 手机:打开全量抽屉
|
|
4640
|
+
setOpen(!nav.classList.contains('drawer-open'));
|
|
4641
|
+
} else if (window.matchMedia('(min-width: 721px) and (max-width: 1023px)').matches) {
|
|
4642
|
+
// 平板:展开 / 收回 60px 图标条
|
|
4643
|
+
setOpen(!nav.classList.contains('expanded'));
|
|
4644
|
+
} else {
|
|
4645
|
+
// 桌面:收起 / 展开侧栏,记住选择
|
|
4646
|
+
const compact = !document.body.classList.contains('nav-compact');
|
|
4647
|
+
document.body.classList.toggle('nav-compact', compact);
|
|
4648
|
+
try { localStorage.setItem(NAV_COMPACT_KEY, compact ? '1' : '0'); } catch {}
|
|
4649
|
+
toggle.setAttribute('aria-expanded', String(!compact));
|
|
4650
|
+
toggle.title = compact ? '展开侧栏' : '收起侧栏';
|
|
4651
|
+
}
|
|
4652
|
+
});
|
|
4653
|
+
|
|
4654
|
+
// 分组标题点击 = 折叠 / 展开下级菜单(snowluma 那种手风琴)
|
|
4655
|
+
$$('.app-nav .tab-parent').forEach((p) => {
|
|
4656
|
+
p.addEventListener('click', (e) => {
|
|
4657
|
+
e.stopPropagation();
|
|
4658
|
+
const g = p.closest('.tab-group');
|
|
4659
|
+
if (!g) return;
|
|
4660
|
+
// 收起态下点分组图标 = 「我想看这一组」,直接展开侧栏并展开该组
|
|
4661
|
+
if (document.body.classList.contains('nav-compact')
|
|
4662
|
+
&& window.matchMedia('(min-width: 1024px)').matches) {
|
|
4663
|
+
document.body.classList.remove('nav-compact');
|
|
4664
|
+
try { localStorage.setItem(NAV_COMPACT_KEY, '0'); } catch {}
|
|
4665
|
+
setGroupCollapsed(g, false);
|
|
4666
|
+
return;
|
|
4667
|
+
}
|
|
4668
|
+
setGroupCollapsed(g, !g.classList.contains('group-collapsed'));
|
|
4669
|
+
});
|
|
4670
|
+
});
|
|
4671
|
+
|
|
4672
|
+
more?.addEventListener('click', (e) => {
|
|
4673
|
+
e.stopPropagation();
|
|
4674
|
+
setOpen(true);
|
|
4675
|
+
});
|
|
4676
|
+
close?.addEventListener('click', (e) => {
|
|
4677
|
+
e.stopPropagation();
|
|
4678
|
+
setOpen(false);
|
|
4679
|
+
});
|
|
4680
|
+
document.addEventListener('keydown', (e) => {
|
|
4681
|
+
if (e.key === 'Escape') setOpen(false);
|
|
4682
|
+
});
|
|
4683
|
+
|
|
4684
|
+
// 初始化:先恢复偏好,再补一次外观同步(无 hash 时 activateTab 不会被调用)
|
|
4685
|
+
applyNavPrefs();
|
|
4686
|
+
if (toggle) {
|
|
4687
|
+
toggle.title = document.body.classList.contains('nav-compact') ? '展开侧栏' : '收起侧栏';
|
|
4688
|
+
}
|
|
4689
|
+
syncNav(location.hash.slice(1) || 'overview');
|
|
4690
|
+
initTableLabels();
|
|
4691
|
+
initThemeColor();
|
|
4692
|
+
})();
|
package/web/index.html
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
<html lang="zh-CN">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#f8f5f1">
|
|
6
7
|
<title>栖 Roost · 控制台</title>
|
|
7
8
|
<!-- 主题脚本必须外置:CSP 的 script-src 'self' 会拦掉内联脚本 -->
|
|
8
9
|
<script src="/theme-boot.js"></script>
|
|
@@ -11,6 +12,11 @@
|
|
|
11
12
|
<body>
|
|
12
13
|
|
|
13
14
|
<header class="topbar">
|
|
15
|
+
<button class="nav-toggle" id="navToggle" aria-label="打开导航" aria-expanded="false">
|
|
16
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
17
|
+
<path d="M4 7h16M4 12h16M4 17h16"/>
|
|
18
|
+
</svg>
|
|
19
|
+
</button>
|
|
14
20
|
<div class="brand" title="栖 Roost · 倦飞之鸟,终有一栖">
|
|
15
21
|
<span class="logo" aria-hidden="true">栖</span>
|
|
16
22
|
<div>
|
|
@@ -42,67 +48,132 @@
|
|
|
42
48
|
</div>
|
|
43
49
|
</div>
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
<!--
|
|
52
|
+
导航:三档布局共用这一份 DOM,靠 CSS 变形(侧栏 / 图标条 / 底部导航 + 抽屉)。
|
|
53
|
+
不要复制出第二套 [data-tab] 按钮 —— activateTab() 用 `b === btn` 高亮,
|
|
54
|
+
重复 data-tab 会导致只有第一处亮。
|
|
55
|
+
.tab-group / .tab-parent / .tab-menu 三件套和 #pluginPageItems 是
|
|
56
|
+
closeTabMenus()、loadPluginPages() 的硬依赖,类名与 id 一律不能改。
|
|
57
|
+
-->
|
|
58
|
+
<nav class="app-nav" id="mainNav" aria-label="主导航">
|
|
59
|
+
<div class="nav-head">
|
|
60
|
+
<span class="nav-brand">栖 <span>Roost</span></span>
|
|
61
|
+
<button class="nav-close" id="navClose" aria-label="收起导航">
|
|
62
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
63
|
+
<path d="M6 6l12 12M18 6L6 18"/>
|
|
54
64
|
</svg>
|
|
55
65
|
</button>
|
|
56
|
-
<div class="tab-menu" hidden>
|
|
57
|
-
<button class="tab-item" data-tab="stats">统计</button>
|
|
58
|
-
<button class="tab-item" data-tab="events">事件</button>
|
|
59
|
-
<button class="tab-item" data-tab="reach">可达性</button>
|
|
60
|
-
<button class="tab-item" data-tab="delivery">投递</button>
|
|
61
|
-
<button class="tab-item" data-tab="ledger">账本</button>
|
|
62
|
-
<button class="tab-item" data-tab="logs">日志</button>
|
|
63
|
-
</div>
|
|
64
66
|
</div>
|
|
65
67
|
|
|
66
|
-
<div class="
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
<div class="nav-scroll">
|
|
69
|
+
<div class="tab-group" data-group="run">
|
|
70
|
+
<button class="tab tab-parent" data-icon="运" aria-label="运行" aria-haspopup="true" aria-expanded="false">
|
|
71
|
+
<svg class="nav-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
72
|
+
<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>
|
|
73
|
+
<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>
|
|
74
|
+
</svg>
|
|
75
|
+
<span class="nav-label">运行</span>
|
|
76
|
+
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
77
|
+
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
78
|
+
<path d="M6 9l6 6 6-6"/>
|
|
79
|
+
</svg>
|
|
80
|
+
</button>
|
|
81
|
+
<div class="tab-menu" hidden>
|
|
82
|
+
<button class="tab-item active" data-tab="overview" data-primary>概览</button>
|
|
83
|
+
<button class="tab-item" data-tab="stats">统计</button>
|
|
84
|
+
<button class="tab-item" data-tab="logs" data-primary>日志</button>
|
|
85
|
+
</div>
|
|
78
86
|
</div>
|
|
79
|
-
</div>
|
|
80
87
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<div class="tab-menu
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
<div class="tab-group" data-group="diagnose">
|
|
89
|
+
<button class="tab tab-parent" data-icon="诊" aria-label="诊断" aria-haspopup="true" aria-expanded="false">
|
|
90
|
+
<svg class="nav-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
91
|
+
<path d="M2 12h4l3-7 4 14 3-7h6"/>
|
|
92
|
+
</svg>
|
|
93
|
+
<span class="nav-label">诊断</span>
|
|
94
|
+
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
95
|
+
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
96
|
+
<path d="M6 9l6 6 6-6"/>
|
|
97
|
+
</svg>
|
|
98
|
+
</button>
|
|
99
|
+
<div class="tab-menu" hidden>
|
|
100
|
+
<button class="tab-item" data-tab="events">事件</button>
|
|
101
|
+
<button class="tab-item" data-tab="reach">可达性</button>
|
|
102
|
+
<button class="tab-item" data-tab="delivery">投递</button>
|
|
103
|
+
<button class="tab-item" data-tab="ledger">账本</button>
|
|
104
|
+
</div>
|
|
95
105
|
</div>
|
|
96
|
-
</div>
|
|
97
106
|
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
<div class="tab-group" data-group="manage">
|
|
108
|
+
<button class="tab tab-parent" data-icon="管" aria-label="管理" aria-haspopup="true" aria-expanded="false">
|
|
109
|
+
<svg class="nav-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
110
|
+
<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h9M17 18h3"/>
|
|
111
|
+
<circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="18" r="2"/>
|
|
112
|
+
</svg>
|
|
113
|
+
<span class="nav-label">管理</span>
|
|
114
|
+
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
115
|
+
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
116
|
+
<path d="M6 9l6 6 6-6"/>
|
|
117
|
+
</svg>
|
|
118
|
+
</button>
|
|
119
|
+
<div class="tab-menu" hidden>
|
|
120
|
+
<button class="tab-item" data-tab="config" data-primary>配置</button>
|
|
121
|
+
<!-- 身份:QQ号 / 群号 ↔ openid。常用页而不是设置项 -->
|
|
122
|
+
<button class="tab-item" data-tab="identity">身份</button>
|
|
123
|
+
<button class="tab-item" data-tab="groups">群组</button>
|
|
124
|
+
<button class="tab-item" data-tab="roles">权限</button>
|
|
125
|
+
<!-- 通知:SMTP 邮箱配置 + 普通成员自助绑定 -->
|
|
126
|
+
<button class="tab-item" data-tab="notify">通知</button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
100
129
|
|
|
101
|
-
|
|
102
|
-
|
|
130
|
+
<div class="tab-group" data-group="plugins">
|
|
131
|
+
<button class="tab tab-parent" data-icon="扩" aria-label="扩展" aria-haspopup="true" aria-expanded="false">
|
|
132
|
+
<svg class="nav-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
133
|
+
<path d="M4 4h6v2a2 2 0 1 0 4 0V4h6v6h-2a2 2 0 1 0 0 4h2v6h-6v-2a2 2 0 1 0-4 0v2H4v-6h2a2 2 0 1 0 0-4H4V4z"/>
|
|
134
|
+
</svg>
|
|
135
|
+
<span class="nav-label">扩展</span>
|
|
136
|
+
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
137
|
+
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
138
|
+
<path d="M6 9l6 6 6-6"/>
|
|
139
|
+
</svg>
|
|
140
|
+
</button>
|
|
141
|
+
<div class="tab-menu" hidden>
|
|
142
|
+
<button class="tab-item" data-tab="plugins" data-primary>插件管理</button>
|
|
143
|
+
<!-- 插件注册的页面挂到这里,见 loadPluginPages() -->
|
|
144
|
+
<div class="tab-menu-sep" id="pluginPageSep" hidden></div>
|
|
145
|
+
<div class="tab-menu-label" id="pluginPageLabel" hidden>插件页面</div>
|
|
146
|
+
<div class="tab-menu-sub" id="pluginPageItems"></div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div class="tab-group" data-group="system">
|
|
151
|
+
<button class="tab tab-parent" data-icon="系" aria-label="系统" aria-haspopup="true" aria-expanded="false">
|
|
152
|
+
<svg class="nav-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
153
|
+
<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7.5h.01"/>
|
|
154
|
+
</svg>
|
|
155
|
+
<span class="nav-label">系统</span>
|
|
156
|
+
<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
157
|
+
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
158
|
+
<path d="M6 9l6 6 6-6"/>
|
|
159
|
+
</svg>
|
|
160
|
+
</button>
|
|
161
|
+
<div class="tab-menu" hidden>
|
|
162
|
+
<button class="tab-item" data-tab="about">关于</button>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
103
166
|
|
|
104
|
-
<button class="
|
|
167
|
+
<button class="nav-more" id="navMore" aria-label="全部功能" aria-expanded="false">
|
|
168
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
169
|
+
<circle cx="5" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="19" cy="12" r="1.4"/>
|
|
170
|
+
</svg>
|
|
171
|
+
<span>更多</span>
|
|
172
|
+
</button>
|
|
105
173
|
</nav>
|
|
174
|
+
|
|
175
|
+
<div class="topbar-spacer"></div>
|
|
176
|
+
<div class="crumb" id="navCrumb">概览</div>
|
|
106
177
|
<button class="icon-btn" id="btnTheme" title="切换日间 / 夜间" aria-label="切换主题">
|
|
107
178
|
<svg class="ic-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
108
179
|
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -517,6 +588,22 @@
|
|
|
517
588
|
</label>
|
|
518
589
|
</div>
|
|
519
590
|
|
|
591
|
+
<div class="box">
|
|
592
|
+
<div class="box-hd"><h3>发图兼容</h3><span class="hint">默认关闭</span></div>
|
|
593
|
+
<label class="switch">
|
|
594
|
+
<input type="checkbox" name="mediaPlaceholderSpace" id="mediaPlaceholderSpace">
|
|
595
|
+
发图片时带一个空格(v1.0.6 之前的行为)
|
|
596
|
+
</label>
|
|
597
|
+
<div class="hint" id="mediaSpaceMsg" style="margin-top:4px"></div>
|
|
598
|
+
<div class="hint" style="margin-top:8px">
|
|
599
|
+
只发一张图时,框架本来不带正文,QQ 里就是干净的一张图。打开后会在富媒体消息里
|
|
600
|
+
补一个空格当正文,回到旧版排版:图片被挤向一侧、外面多出一块空白文字区。<br>
|
|
601
|
+
可能有奇妙的作用(),<b>勾选后立即生效、不用点保存也不用重启</b>。
|
|
602
|
+
带说明文字的图片(<code>replyImage(..., { text })</code>)不受影响;
|
|
603
|
+
视频 / 语音 / 文件的 content 官方会忽略,勾上也没有副作用。
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
|
|
520
607
|
<div class="box">
|
|
521
608
|
<div class="box-hd"><h3>群管能力</h3><span class="hint">禁言 / 撤回已开放;群成员与踢人需官方白名单</span></div>
|
|
522
609
|
<label class="switch">
|
package/web/style.css
CHANGED
|
@@ -1425,3 +1425,480 @@ label.switch.small { font-size: 12.5px; color: var(--muted); }
|
|
|
1425
1425
|
white-space: nowrap;
|
|
1426
1426
|
overflow-wrap: anywhere;
|
|
1427
1427
|
}
|
|
1428
|
+
|
|
1429
|
+
/* ============================================================
|
|
1430
|
+
响应式布局重构(2026-11-06)
|
|
1431
|
+
------------------------------------------------------------
|
|
1432
|
+
三档共用同一份导航 DOM,靠 CSS 变形:
|
|
1433
|
+
≥1024 固定分组侧栏
|
|
1434
|
+
721–1023 60px 图标条(可展开)
|
|
1435
|
+
≤720 底部高频 4 项 + 全量抽屉
|
|
1436
|
+
规则一律放文件末尾、用 .app-nav 提高特异性覆盖旧 .tabs 规则,
|
|
1437
|
+
不改 :root 变量名 —— server.js 的 PAGE_THEME 镜像着同一套变量名,
|
|
1438
|
+
改名会让插件页面深浅色失效。
|
|
1439
|
+
============================================================ */
|
|
1440
|
+
|
|
1441
|
+
/* ---------- 顶栏新增件 ---------- */
|
|
1442
|
+
.topbar-spacer { flex: 1; min-width: 0; }
|
|
1443
|
+
.crumb { font-size: 13px; color: var(--muted); white-space: nowrap; }
|
|
1444
|
+
.crumb::before { content: '/'; margin-right: 8px; color: var(--faint); }
|
|
1445
|
+
.nav-toggle {
|
|
1446
|
+
display: none; flex: none; place-items: center;
|
|
1447
|
+
width: 40px; height: 40px; padding: 9px;
|
|
1448
|
+
border: 1px solid var(--border); border-radius: 9px;
|
|
1449
|
+
background: var(--panel); color: var(--muted); cursor: pointer;
|
|
1450
|
+
}
|
|
1451
|
+
.nav-toggle svg { width: 20px; height: 20px; display: block; }
|
|
1452
|
+
.nav-close {
|
|
1453
|
+
display: none; place-items: center;
|
|
1454
|
+
width: 40px; height: 40px; padding: 9px;
|
|
1455
|
+
border: 0; background: none; color: var(--muted); cursor: pointer;
|
|
1456
|
+
}
|
|
1457
|
+
.nav-close svg { width: 20px; height: 20px; display: block; }
|
|
1458
|
+
|
|
1459
|
+
/* ---------- 宽屏:固定分组侧栏 ---------- */
|
|
1460
|
+
.app-nav {
|
|
1461
|
+
position: fixed; top: 60px; left: 0; bottom: 0; width: 240px; z-index: 30;
|
|
1462
|
+
display: flex; flex-direction: column;
|
|
1463
|
+
background: var(--panel); border-right: 1px solid var(--border);
|
|
1464
|
+
overflow: hidden;
|
|
1465
|
+
transition: width .26s var(--ease);
|
|
1466
|
+
}
|
|
1467
|
+
.app-nav .nav-head { display: none; }
|
|
1468
|
+
.app-nav .nav-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 10px 20px; }
|
|
1469
|
+
/* 滚动条藏起来但保留滚动能力:侧栏那条灰色粗条在窄窗口里很抢眼 */
|
|
1470
|
+
.app-nav .nav-scroll { scrollbar-width: none; -ms-overflow-style: none; }
|
|
1471
|
+
.app-nav .nav-scroll::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
|
1472
|
+
.app-nav .tab-group { position: static; display: block; margin-bottom: 14px; }
|
|
1473
|
+
.app-nav .tab-parent {
|
|
1474
|
+
width: 100%; height: auto; padding: 4px 12px 6px;
|
|
1475
|
+
/* 分组全部折叠后侧栏只剩这 5 行标题,11px 太细、撑不住整列;
|
|
1476
|
+
平板展开态共用这条规则。手机抽屉另有显式 11px 覆盖,不受影响。 */
|
|
1477
|
+
font-size: 13px; font-weight: 600; letter-spacing: .02em;
|
|
1478
|
+
/* 折叠后整列只剩这 5 行标题,--faint 太糊;提到 --muted,仍比菜单项 --text-2 弱一档 */
|
|
1479
|
+
color: var(--muted); cursor: pointer; gap: 7px;
|
|
1480
|
+
/* 必须显式左对齐:平板 / 收起态会把 justify-content 改成 center,
|
|
1481
|
+
切回宽屏时若没覆盖,分组标题会飘到中间,和菜单项错位 */
|
|
1482
|
+
justify-content: flex-start;
|
|
1483
|
+
}
|
|
1484
|
+
.app-nav .tab-parent::after { display: none; }
|
|
1485
|
+
/* 收起态用矢量图标,不再用分组首字 */
|
|
1486
|
+
.app-nav .tab-parent .nav-ico {
|
|
1487
|
+
display: none; width: 18px; height: 18px; flex: none; opacity: 0;
|
|
1488
|
+
transition: opacity .2s var(--ease);
|
|
1489
|
+
}
|
|
1490
|
+
.app-nav .tab-parent .nav-label {
|
|
1491
|
+
flex: 0 1 auto; min-width: 0; max-width: 120px; opacity: 1;
|
|
1492
|
+
white-space: nowrap; overflow: hidden;
|
|
1493
|
+
transition: max-width .26s var(--ease), opacity .18s var(--ease);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/*
|
|
1497
|
+
* 桌面分组常驻展开。closeTabMenus() 会给 .tab-menu 设 hidden=true,
|
|
1498
|
+
* 这里用更高特异性覆盖,避免为了「常驻」去改业务侧的下拉开合逻辑。
|
|
1499
|
+
* 代价仅是 aria-expanded 语义不准,不影响任何功能。
|
|
1500
|
+
*/
|
|
1501
|
+
.app-nav .tab-menu,
|
|
1502
|
+
.app-nav .tab-menu[hidden] {
|
|
1503
|
+
display: block; position: static; min-width: 0; padding: 0;
|
|
1504
|
+
border: 0; border-radius: 0; box-shadow: none; animation: none;
|
|
1505
|
+
max-height: 1600px; opacity: 1; overflow: hidden;
|
|
1506
|
+
transition: max-height .26s var(--ease), opacity .18s var(--ease);
|
|
1507
|
+
}
|
|
1508
|
+
/* 插件页面可能很多,给个上限,别让整个分组把侧栏撑到天上去 */
|
|
1509
|
+
.app-nav .tab-menu-sub { max-height: min(52vh, 420px); overflow-y: auto; }
|
|
1510
|
+
.app-nav .tab-item {
|
|
1511
|
+
display: block; width: 100%; text-align: left;
|
|
1512
|
+
min-height: 38px; padding: 8px 12px; margin: 1px 0;
|
|
1513
|
+
border-radius: 8px; font-size: 13px; color: var(--text-2);
|
|
1514
|
+
}
|
|
1515
|
+
.app-nav .tab-item:hover { background: var(--panel-2); color: var(--text); }
|
|
1516
|
+
.app-nav .tab-item.active { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
|
|
1517
|
+
.app-nav .tab-menu-label { padding: 6px 10px 2px; }
|
|
1518
|
+
.app-nav .nav-more { display: none; }
|
|
1519
|
+
|
|
1520
|
+
main {
|
|
1521
|
+
margin-left: 240px; max-width: none; padding: 24px 32px 48px;
|
|
1522
|
+
transition: margin-left .26s var(--ease);
|
|
1523
|
+
}
|
|
1524
|
+
main > .panel { max-width: 1400px; margin-inline: auto; }
|
|
1525
|
+
|
|
1526
|
+
/* ---------- 平板:60px 图标条,汉堡可展开 ---------- */
|
|
1527
|
+
@media (min-width: 721px) and (max-width: 1023px) {
|
|
1528
|
+
.nav-toggle { display: grid; }
|
|
1529
|
+
.app-nav { width: 60px; }
|
|
1530
|
+
.app-nav .nav-scroll { padding: 10px 6px; }
|
|
1531
|
+
.app-nav .tab-group { margin-bottom: 6px; }
|
|
1532
|
+
.app-nav .tab-parent { height: 44px; padding: 0; justify-content: center; color: var(--muted); }
|
|
1533
|
+
.app-nav .tab-parent .nav-ico { display: block; opacity: 1; }
|
|
1534
|
+
.app-nav .tab-parent .nav-label { max-width: 0; opacity: 0; }
|
|
1535
|
+
.app-nav .tab-parent .chev { display: none; }
|
|
1536
|
+
.app-nav .tab-menu,
|
|
1537
|
+
.app-nav .tab-menu[hidden] { max-height: 0; opacity: 0; pointer-events: none; }
|
|
1538
|
+
main { margin-left: 60px; padding: 20px 20px 40px; }
|
|
1539
|
+
|
|
1540
|
+
.app-nav.expanded { width: 240px; box-shadow: var(--shadow-lg); }
|
|
1541
|
+
.app-nav.expanded .tab-parent { height: auto; padding: 2px 10px 6px; justify-content: flex-start; }
|
|
1542
|
+
.app-nav.expanded .tab-parent .nav-label { max-width: 120px; opacity: 1; }
|
|
1543
|
+
.app-nav.expanded .tab-parent .chev { display: block; }
|
|
1544
|
+
.app-nav.expanded .tab-menu,
|
|
1545
|
+
.app-nav.expanded .tab-menu[hidden] { max-height: 1600px; opacity: 1; pointer-events: auto; }
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/* ---------- 手机:底部导航 + 全量抽屉 ---------- */
|
|
1549
|
+
@media (max-width: 720px) {
|
|
1550
|
+
.topbar { height: 54px; padding: 0 10px; gap: 8px; flex-wrap: nowrap; }
|
|
1551
|
+
.nav-toggle { display: grid; width: 42px; height: 42px; padding: 10px; }
|
|
1552
|
+
.brand { display: none; }
|
|
1553
|
+
.bot-switch { flex: 1; min-width: 0; }
|
|
1554
|
+
.bot-trigger { width: 100%; }
|
|
1555
|
+
.bot-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1556
|
+
.topbar-spacer, .crumb { display: none; }
|
|
1557
|
+
.icon-btn { width: 40px; height: 40px; }
|
|
1558
|
+
.status-pill { padding: 8px 10px; }
|
|
1559
|
+
.status-pill #connText, .status-pill .pill-sep, .status-pill #connAction { display: none; }
|
|
1560
|
+
|
|
1561
|
+
.app-nav {
|
|
1562
|
+
top: auto; bottom: 0; left: 0; right: 0; width: auto;
|
|
1563
|
+
height: calc(58px + env(safe-area-inset-bottom));
|
|
1564
|
+
flex-direction: row; align-items: stretch; z-index: 40;
|
|
1565
|
+
border-right: 0; border-top: 1px solid var(--border);
|
|
1566
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
1567
|
+
}
|
|
1568
|
+
.app-nav .nav-head { display: none; }
|
|
1569
|
+
.app-nav .nav-scroll {
|
|
1570
|
+
display: flex; flex: 1; min-width: 0; align-items: stretch;
|
|
1571
|
+
padding: 0; overflow: visible;
|
|
1572
|
+
}
|
|
1573
|
+
/* display:contents 让分散在 5 个分组里的高频项直接参与底栏 flex */
|
|
1574
|
+
.app-nav .tab-group,
|
|
1575
|
+
.app-nav .tab-menu,
|
|
1576
|
+
.app-nav .tab-menu[hidden] {
|
|
1577
|
+
display: contents;
|
|
1578
|
+
max-height: none; opacity: 1; overflow: visible; pointer-events: auto;
|
|
1579
|
+
}
|
|
1580
|
+
.app-nav .tab-parent,
|
|
1581
|
+
.app-nav .tab-menu-sep,
|
|
1582
|
+
.app-nav .tab-menu-label,
|
|
1583
|
+
.app-nav .tab-menu-sub { display: none; }
|
|
1584
|
+
.app-nav .tab-item:not([data-primary]) { display: none; }
|
|
1585
|
+
.app-nav .tab-item[data-primary] {
|
|
1586
|
+
display: flex; flex: 1 1 0; min-width: 0; min-height: 0; height: auto;
|
|
1587
|
+
align-items: center; justify-content: center;
|
|
1588
|
+
padding: 6px 2px; margin: 0; border-radius: 0;
|
|
1589
|
+
font-size: 12px; text-align: center;
|
|
1590
|
+
transition: color .18s var(--ease), box-shadow .18s var(--ease);
|
|
1591
|
+
}
|
|
1592
|
+
.app-nav .tab-item[data-primary].active {
|
|
1593
|
+
background: transparent; color: var(--primary);
|
|
1594
|
+
box-shadow: inset 0 2px 0 var(--primary);
|
|
1595
|
+
}
|
|
1596
|
+
.app-nav .nav-more {
|
|
1597
|
+
display: flex; flex: 0 0 58px; flex-direction: column;
|
|
1598
|
+
align-items: center; justify-content: center; gap: 2px;
|
|
1599
|
+
border: 0; background: none; color: var(--muted);
|
|
1600
|
+
font: inherit; font-size: 11px; cursor: pointer;
|
|
1601
|
+
}
|
|
1602
|
+
.app-nav .nav-more svg { width: 18px; height: 18px; }
|
|
1603
|
+
main { margin-left: 0; padding: 14px 12px calc(74px + env(safe-area-inset-bottom)); }
|
|
1604
|
+
main > .panel { max-width: none; }
|
|
1605
|
+
|
|
1606
|
+
/*
|
|
1607
|
+
* .card .v 原本是 white-space:nowrap(宽屏好看),但手机 2 列网格里
|
|
1608
|
+
* grid item 的 min-width:auto 会被「undefined MB」「凭证有效期」这类长值撑开,
|
|
1609
|
+
* 整页 scrollWidth 变大,顶栏右侧按钮和底栏「更多」一起被挤出视口。
|
|
1610
|
+
* 必须 minmax(0,1fr) + 允许换行,双管齐下。
|
|
1611
|
+
*/
|
|
1612
|
+
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
1613
|
+
.cards > .card { min-width: 0; }
|
|
1614
|
+
.card .v { white-space: normal; overflow-wrap: anywhere; font-size: 17px; }
|
|
1615
|
+
.topbar, .app-nav { max-width: 100vw; }
|
|
1616
|
+
.status-pill { flex: none; }
|
|
1617
|
+
.app-nav .nav-scroll { min-width: 0; overflow: hidden; }
|
|
1618
|
+
|
|
1619
|
+
/* 抽屉:点「更多」后铺满全屏,列出全部分组 */
|
|
1620
|
+
.app-nav.drawer-open {
|
|
1621
|
+
top: 0; height: 100dvh; flex-direction: column;
|
|
1622
|
+
}
|
|
1623
|
+
.app-nav.drawer-open .nav-head {
|
|
1624
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
1625
|
+
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
|
1626
|
+
}
|
|
1627
|
+
.nav-brand { font-family: var(--display); font-size: 16px; font-weight: 700; color: var(--text); }
|
|
1628
|
+
.nav-brand span { font-family: var(--sans); font-size: 10px; letter-spacing: .16em; color: var(--muted); }
|
|
1629
|
+
.app-nav.drawer-open .nav-scroll { display: block; overflow-y: auto; padding: 12px 12px 20px; }
|
|
1630
|
+
.app-nav.drawer-open .tab-group,
|
|
1631
|
+
.app-nav.drawer-open .tab-menu,
|
|
1632
|
+
.app-nav.drawer-open .tab-menu[hidden] {
|
|
1633
|
+
display: block; position: static;
|
|
1634
|
+
max-height: none; opacity: 1; overflow: visible; pointer-events: auto;
|
|
1635
|
+
}
|
|
1636
|
+
.app-nav.drawer-open .tab-parent {
|
|
1637
|
+
display: flex; height: auto; padding: 6px 10px;
|
|
1638
|
+
font-size: 11px; color: var(--faint);
|
|
1639
|
+
}
|
|
1640
|
+
.app-nav.drawer-open .tab-parent .nav-ico {
|
|
1641
|
+
display: block; opacity: 1; width: 16px; height: 16px;
|
|
1642
|
+
}
|
|
1643
|
+
.app-nav.drawer-open .tab-parent .nav-label { max-width: 120px; opacity: 1; }
|
|
1644
|
+
.app-nav.drawer-open .tab-item:not([data-primary]),
|
|
1645
|
+
.app-nav.drawer-open .tab-item[data-primary] {
|
|
1646
|
+
display: block; width: 100%; min-height: 44px; padding: 10px 12px;
|
|
1647
|
+
border-radius: 8px; font-size: 14px; text-align: left;
|
|
1648
|
+
}
|
|
1649
|
+
.app-nav.drawer-open .tab-item[data-primary].active { box-shadow: none; background: var(--primary-soft); }
|
|
1650
|
+
.app-nav.drawer-open .tab-menu-sep,
|
|
1651
|
+
.app-nav.drawer-open .tab-menu-label,
|
|
1652
|
+
.app-nav.drawer-open .tab-menu-sub { display: block; }
|
|
1653
|
+
.app-nav.drawer-open .nav-more { display: none; }
|
|
1654
|
+
.nav-close { display: grid; }
|
|
1655
|
+
|
|
1656
|
+
/* ---------- 12 张表全部卡片化 ---------- */
|
|
1657
|
+
.table-wrap { max-height: none; overflow: visible; }
|
|
1658
|
+
table, #tab-ledger table { min-width: 0; width: 100%; table-layout: auto; }
|
|
1659
|
+
thead { display: none; }
|
|
1660
|
+
tbody, tbody tr { display: block; }
|
|
1661
|
+
tbody tr {
|
|
1662
|
+
margin-bottom: 10px; padding: 10px 12px;
|
|
1663
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
|
1664
|
+
}
|
|
1665
|
+
tbody td {
|
|
1666
|
+
display: flex; gap: 10px; align-items: baseline;
|
|
1667
|
+
padding: 4px 0; border: 0; text-align: left;
|
|
1668
|
+
}
|
|
1669
|
+
tbody td::before {
|
|
1670
|
+
content: attr(data-label);
|
|
1671
|
+
flex: 0 0 92px; color: var(--muted); font-size: 12px;
|
|
1672
|
+
}
|
|
1673
|
+
tbody td[colspan]:not(.empty)::before { content: none; }
|
|
1674
|
+
tbody td[colspan] { display: block; text-align: center; padding: 18px 0; }
|
|
1675
|
+
tbody td.num { text-align: left; }
|
|
1676
|
+
/* 账本页原本写死列宽且 table-layout:fixed,窄屏必须一并复位 */
|
|
1677
|
+
#tab-ledger .lg-chat, #tab-ledger .lg-kind, #tab-ledger .lg-num,
|
|
1678
|
+
#tab-ledger .lg-range, #tab-ledger .lg-ops, #tab-ledger .lg-when,
|
|
1679
|
+
#tab-ledger .lg-who {
|
|
1680
|
+
width: auto; white-space: normal; overflow: visible; text-overflow: clip;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/* ---------- 弹窗改全屏 sheet ---------- */
|
|
1684
|
+
.modal-mask { align-items: flex-end; }
|
|
1685
|
+
.modal, .modal.wide {
|
|
1686
|
+
width: 100%; max-width: none; max-height: 92dvh;
|
|
1687
|
+
border-radius: 16px 16px 0 0;
|
|
1688
|
+
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
|
|
1689
|
+
}
|
|
1690
|
+
.pp-body { max-height: none; flex: 1 1 auto; }
|
|
1691
|
+
.modal-actions { flex-wrap: wrap; }
|
|
1692
|
+
.modal-actions > .btn { min-width: 0; flex: 1 1 auto; }
|
|
1693
|
+
.modal-actions > .act-left { margin-right: 0; }
|
|
1694
|
+
.plugin-page { height: calc(100dvh - 170px); min-height: 320px; }
|
|
1695
|
+
.sticky-actions { bottom: calc(58px + env(safe-area-inset-bottom)); }
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/* ---------- 触摸设备:达标触控目标(参考 snowluma 的 pointer:coarse 分支) ---------- */
|
|
1699
|
+
@media (hover: none) and (pointer: coarse) {
|
|
1700
|
+
.icon-btn, .nav-toggle { width: 44px; height: 44px; }
|
|
1701
|
+
.app-nav .tab-item, .pp-tab, .btn.small { min-height: 44px; }
|
|
1702
|
+
.chip-x { min-width: 36px; min-height: 44px; }
|
|
1703
|
+
.status-pill { min-height: 40px; }
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/* ---------- 减少动效 ---------- */
|
|
1707
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1708
|
+
*, *::before, *::after {
|
|
1709
|
+
animation-duration: .01ms !important;
|
|
1710
|
+
transition-duration: .01ms !important;
|
|
1711
|
+
scroll-behavior: auto !important;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/* ============================================================
|
|
1716
|
+
侧栏交互(2026-11-07):收起 / 分组折叠
|
|
1717
|
+
------------------------------------------------------------
|
|
1718
|
+
顶栏汉堡在三档里的职责:
|
|
1719
|
+
≥1024 收起 / 展开侧栏(状态存 localStorage)
|
|
1720
|
+
721–1023 展开 / 收回图标条
|
|
1721
|
+
≤720 打开全量抽屉
|
|
1722
|
+
============================================================ */
|
|
1723
|
+
|
|
1724
|
+
/* 汉堡在桌面也常驻,作为「收起侧栏」开关 */
|
|
1725
|
+
.nav-toggle { display: grid; }
|
|
1726
|
+
|
|
1727
|
+
/* 分组标题可点,chevron 指示折叠态 */
|
|
1728
|
+
.app-nav .tab-parent { cursor: pointer; }
|
|
1729
|
+
.app-nav .tab-group .tab-parent .chev {
|
|
1730
|
+
display: block; width: 12px; height: 12px; opacity: .55;
|
|
1731
|
+
margin-left: auto;
|
|
1732
|
+
transform: rotate(180deg);
|
|
1733
|
+
transition: transform .18s var(--ease);
|
|
1734
|
+
}
|
|
1735
|
+
.app-nav .tab-group.group-collapsed .tab-parent .chev { transform: rotate(0deg); }
|
|
1736
|
+
|
|
1737
|
+
/*
|
|
1738
|
+
* 折叠态只在侧栏形态生效;手机抽屉始终展开全部分组,
|
|
1739
|
+
* 免得在抽屉里还要先点开分组才能找到页签。
|
|
1740
|
+
*/
|
|
1741
|
+
@media (min-width: 721px) {
|
|
1742
|
+
.app-nav .tab-group.group-collapsed .tab-menu,
|
|
1743
|
+
.app-nav .tab-group.group-collapsed .tab-menu[hidden] {
|
|
1744
|
+
max-height: 0; opacity: 0; pointer-events: none;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/* 桌面收起:60px 图标条,复用平板那套 data-icon 首字 */
|
|
1749
|
+
@media (min-width: 1024px) {
|
|
1750
|
+
body.nav-compact .app-nav { width: 60px; }
|
|
1751
|
+
body.nav-compact main { margin-left: 60px; }
|
|
1752
|
+
body.nav-compact .app-nav .nav-scroll { padding: 10px 6px; }
|
|
1753
|
+
body.nav-compact .app-nav .tab-group { margin-bottom: 6px; }
|
|
1754
|
+
body.nav-compact .app-nav .tab-parent {
|
|
1755
|
+
height: 44px; padding: 0; justify-content: center; color: var(--muted);
|
|
1756
|
+
}
|
|
1757
|
+
body.nav-compact .app-nav .tab-parent .nav-ico { display: block; opacity: 1; }
|
|
1758
|
+
body.nav-compact .app-nav .tab-parent .nav-label { max-width: 0; opacity: 0; }
|
|
1759
|
+
body.nav-compact .app-nav .tab-parent .chev { display: none; }
|
|
1760
|
+
body.nav-compact .app-nav .tab-menu,
|
|
1761
|
+
body.nav-compact .app-nav .tab-menu[hidden] {
|
|
1762
|
+
max-height: 0; opacity: 0; pointer-events: none;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/* ---------- 收起 / 折叠的收尾细节 ---------- */
|
|
1767
|
+
|
|
1768
|
+
/* 图标条里没地方放折叠箭头,这两条要写在 chevron 通用规则之后才压得住 */
|
|
1769
|
+
@media (min-width: 721px) and (max-width: 1023px) {
|
|
1770
|
+
.app-nav:not(.expanded) .tab-group .tab-parent .chev { display: none; }
|
|
1771
|
+
}
|
|
1772
|
+
@media (min-width: 1024px) {
|
|
1773
|
+
body.nav-compact .app-nav .tab-group .tab-parent .chev { display: none; }
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/* 抽屉上滑淡入,别硬邦邦地铺满 */
|
|
1777
|
+
@keyframes drawer-in {
|
|
1778
|
+
from { opacity: .55; transform: translateY(14px); }
|
|
1779
|
+
to { opacity: 1; transform: none; }
|
|
1780
|
+
}
|
|
1781
|
+
.app-nav.drawer-open { animation: drawer-in .24s var(--ease); }
|
|
1782
|
+
|
|
1783
|
+
/* 分组标题暗示可点;折叠态不再压暗(整列只剩标题,压暗会糊成一片,
|
|
1784
|
+
折叠状态由 chevron 的旋转方向指示,不靠变淡) */
|
|
1785
|
+
.app-nav .tab-parent:hover { color: var(--text-2); }
|
|
1786
|
+
|
|
1787
|
+
/* ============================================================
|
|
1788
|
+
过渡打磨(2026-11-07):把「跳变」换成连续运动
|
|
1789
|
+
------------------------------------------------------------
|
|
1790
|
+
只追加动效声明,不改任何尺寸 / 颜色 / 布局数值,也不新增 CSS 变量
|
|
1791
|
+
(契约脚本对 cssVarDefs 做双向比对,多一个变量就会 FAIL)。
|
|
1792
|
+
|
|
1793
|
+
对标(§11 调研 + GitHub 动效规范实测 2026-11-07):
|
|
1794
|
+
· 时长阶梯:hover ≈150ms / 状态切换 200–300ms / drawer·modal·accordion 300–400ms
|
|
1795
|
+
(better-react-web-ui「100/300/500」、skillmd animation.md、motionlint STANDARDS.md)
|
|
1796
|
+
· 曲线只留两条:沿用本文件自带 --ease(强 ease-out,进入 / 展开),
|
|
1797
|
+
drawer / bottom-sheet 用 cubic-bezier(.32,.72,0,1)(iOS 曲线)
|
|
1798
|
+
· 只动 transform / opacity,不再给 height / width 增加动画(布局属性是性能反模式)
|
|
1799
|
+
· 入场 scale 不从 0 起(健康区间 .95–.97),退场比入场快约 20%
|
|
1800
|
+
|
|
1801
|
+
现场三类「生硬」根因:
|
|
1802
|
+
1. 切页是 display:none → block,内容凭空出现;
|
|
1803
|
+
2. 分组折叠用 max-height:1600px → 0,前大半程看不出变化、最后一瞬收完;
|
|
1804
|
+
3. 图标 / 箭头靠 display 切换「啪」地出现,颜色硬切,时长普遍只有 .12–.18s。
|
|
1805
|
+
============================================================ */
|
|
1806
|
+
|
|
1807
|
+
/* 1. 切页:内容轻微上移淡入,不再瞬间闪现。
|
|
1808
|
+
弹窗 DOM 在 </main> 之外、sticky-actions 是 position:sticky,
|
|
1809
|
+
所以动画期间的 transform 不会把 fixed 元素锚到面板上。 */
|
|
1810
|
+
@keyframes panel-in {
|
|
1811
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
1812
|
+
to { opacity: 1; transform: none; }
|
|
1813
|
+
}
|
|
1814
|
+
main > .panel.active {
|
|
1815
|
+
animation: panel-in .3s var(--ease) both;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/* 2. 侧栏收起 / 主区让位:两者必须同一节奏,否则会一先一后地错开 */
|
|
1819
|
+
.app-nav { transition: width .34s var(--ease), box-shadow .3s ease; }
|
|
1820
|
+
main { transition: margin-left .34s var(--ease); }
|
|
1821
|
+
|
|
1822
|
+
/* 3. 分组折叠:高度收拢的同时内容同步上移淡出,盖住 max-height 的空转段 */
|
|
1823
|
+
.app-nav .tab-menu,
|
|
1824
|
+
.app-nav .tab-menu[hidden] {
|
|
1825
|
+
transition:
|
|
1826
|
+
max-height .36s var(--ease),
|
|
1827
|
+
opacity .24s ease,
|
|
1828
|
+
transform .36s var(--ease);
|
|
1829
|
+
transform-origin: top center;
|
|
1830
|
+
}
|
|
1831
|
+
@media (min-width: 721px) {
|
|
1832
|
+
.app-nav .tab-group.group-collapsed .tab-menu,
|
|
1833
|
+
.app-nav .tab-group.group-collapsed .tab-menu[hidden] {
|
|
1834
|
+
transform: translateY(-8px);
|
|
1835
|
+
/* 退场比入场快一档:用户已经决定收起,不必等动画慢慢同意 */
|
|
1836
|
+
transition-duration: .28s, .2s, .28s;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/* 4. 文字 / 图标 / 箭头:颜色不硬切,出现时带一点缩放而不是凭空跳出 */
|
|
1841
|
+
@keyframes nav-ico-in {
|
|
1842
|
+
from { opacity: 0; transform: scale(.92); }
|
|
1843
|
+
to { opacity: 1; transform: none; }
|
|
1844
|
+
}
|
|
1845
|
+
.app-nav .tab-parent { transition: color .18s ease, opacity .22s ease; }
|
|
1846
|
+
.app-nav .tab-item {
|
|
1847
|
+
transition: background-color .2s ease, color .2s ease,
|
|
1848
|
+
opacity .2s ease, box-shadow .2s ease;
|
|
1849
|
+
}
|
|
1850
|
+
.app-nav .tab-parent .nav-label {
|
|
1851
|
+
transition: max-width .34s var(--ease), opacity .26s ease;
|
|
1852
|
+
}
|
|
1853
|
+
.app-nav .tab-parent .nav-ico { transition: opacity .26s ease; }
|
|
1854
|
+
.app-nav .tab-group .tab-parent .chev {
|
|
1855
|
+
transition: transform .3s var(--ease), opacity .22s ease;
|
|
1856
|
+
}
|
|
1857
|
+
@media (min-width: 1024px) {
|
|
1858
|
+
body.nav-compact .app-nav .tab-parent .nav-ico {
|
|
1859
|
+
animation: nav-ico-in .3s var(--ease) both;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
@media (min-width: 721px) and (max-width: 1023px) {
|
|
1863
|
+
.app-nav.expanded .tab-parent .chev {
|
|
1864
|
+
animation: nav-ico-in .3s var(--ease) both;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/* 5. 手机抽屉:容器淡入上滑,内容再分层入场,不再整块糊上来 */
|
|
1869
|
+
@keyframes drawer-in {
|
|
1870
|
+
from { opacity: .35; transform: translateY(22px); }
|
|
1871
|
+
to { opacity: 1; transform: none; }
|
|
1872
|
+
}
|
|
1873
|
+
@keyframes drawer-content-in {
|
|
1874
|
+
from { opacity: 0; transform: translateY(14px); }
|
|
1875
|
+
to { opacity: 1; transform: none; }
|
|
1876
|
+
}
|
|
1877
|
+
@media (max-width: 720px) {
|
|
1878
|
+
.app-nav.drawer-open {
|
|
1879
|
+
box-shadow: var(--shadow-lg);
|
|
1880
|
+
animation: drawer-in .36s cubic-bezier(.32, .72, 0, 1) both;
|
|
1881
|
+
}
|
|
1882
|
+
.app-nav.drawer-open .nav-head {
|
|
1883
|
+
animation: drawer-content-in .38s var(--ease) .04s both;
|
|
1884
|
+
}
|
|
1885
|
+
.app-nav.drawer-open .nav-scroll {
|
|
1886
|
+
animation: drawer-content-in .42s var(--ease) .08s both;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/* 6. 弹窗:入场位移看得见;手机改成从底部滑上来的 sheet */
|
|
1891
|
+
@keyframes modal-fade { from { opacity: 0 } to { opacity: 1 } }
|
|
1892
|
+
@keyframes modal-pop {
|
|
1893
|
+
from { opacity: 0; transform: translateY(14px) scale(.97); }
|
|
1894
|
+
to { opacity: 1; transform: none; }
|
|
1895
|
+
}
|
|
1896
|
+
@keyframes sheet-in {
|
|
1897
|
+
from { opacity: .7; transform: translateY(100%); }
|
|
1898
|
+
to { opacity: 1; transform: none; }
|
|
1899
|
+
}
|
|
1900
|
+
.modal-mask { animation: modal-fade .24s ease both; }
|
|
1901
|
+
.modal { animation: modal-pop .28s var(--ease) both; }
|
|
1902
|
+
@media (max-width: 720px) {
|
|
1903
|
+
.modal { animation: sheet-in .36s cubic-bezier(.32, .72, 0, 1) both; }
|
|
1904
|
+
}
|