meowpanel: 新增 /api/audio(audiod MSG_AUDIO_QUERY→STATE 透传, 不在时 available:false)+ 前端「音频」页(5s轮询只读)与「字体」页(列表/上传进度态/导出/409冲突确认force重试); 后端 md5 015f9cf1

This commit is contained in:
MeowOS committed 2026-09-01 03:40:28 +08:00
1 parent f149e24f28
commit 7cb1bdace4
6 files changed
+253 -7

No files matched your search

+31 -5
View File
@@ -28,7 +28,7 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。
- 产出(本次构建实测):
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
体积约 **218 KB**(固件阈值内)。md5:`51ffc183403aa20e0d19d0f811c300b7`(本次含字体库 tar.gz 导入/导出 + 排序修复)。
体积约 **218 KB**(固件阈值内)。md5:`015f9cf1c12e36d133b01cf6b6648edd`(本次含 `/api/audio` 音频状态端点 + 前端「音频」「字体」页)。
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **148 KB**。
- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自
`meow_verify/sha256.c` 公有领域参考写法,前缀 `mp_sha256_*` 避免符号冲突;**未改 `meow_verify/` 目录**)。
@@ -112,6 +112,7 @@ http://<host>.local:8080/
| GET | `/api/fonts` | 字体库列表(扫描 `MeowOS/fonts/<family>/<weight>/` 三件套齐) | ✅(无卡→503) |
| POST | `/api/fonts/import` | 上传字体库 `.tar.gz`(multipart `file`,可带 `?force=1` 覆盖) | ✅(缺件/坏json/穿越→400,冲突→409,超限→413) |
| GET | `/api/fonts/export` | 导出 `<family>/<weight>/` 三件套为 `.tar.gz`(`?family=&weight=`) | ✅(缺件→404) |
| GET | `/api/audio` | 音频状态快照:meow_audiod IPC `MSG_AUDIO_QUERY→STATE` 透传(当前流/路由/音量/underrun,见 §5.12) | ✅(audiod 不在→`available:false`,不报错) |
| GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ |
| GET | `/api/apps/:pkg` | 应用详情 | ✅ |
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
@@ -306,6 +307,27 @@ http://<host>.local:8080/
再导入→逐字节一致;GNU tar/`tarfile` 均能读;目录穿越 `../`/绝对路径/软链成员/缺件/坏 font.json/多出条目/
子目录包裹/非 gzip——全部拒绝;打包文件名 `<family>-<weight>.font.tar.gz`。见 §7 宿主测试备注。
### 5.12 音频状态(`/api/audio`,新增,AUDIOD_DESIGN §8 面板对接)
- **数据源**:`meow_audiod` 独占音频守护(板载进固件,svcd 托管;HibyOS 模式按门控不启动)。
后端连 `/usr/data/meow_audio.sock`(可被 `--sock-audio` / env `MEOW_AUDIO_SOCK` 覆盖,宿主/qemu 测试用,
与 audiod CLI 同 env),发 `MSG_AUDIO_QUERY`(0x00C7) 空负载,收 `MSG_AUDIO_STATE`(0x00C8) 文本帧
(`active=.. app=.. sid=.. rate=.. ch=.. fmt=.. written=.. played=.. underrun=.. paused=.. route_mode=.. port=.. vol=..`,
空格分隔 k=v、NUL 结尾)。**audiod 无 MSG_HELLO 握手**(见 meow_audio.c CLI),与 perf/power/launcher 路径不同。
- **响应**:解析 k=v 为 JSON——`{available:true, active, app, sid, rate, ch, fmt, written_frames, played_frames,
underrun, paused, route_mode("auto"/"manual"), port, vol}`;`port` 为 amixer numid=9 路由口
(0=空闲 1=lineout 2=3.5 3=4.4 4=Type-C SPDIF;宿主无 amixer 时为 -1)。
- **audiod 不在**(socket 缺失/连接拒/超时/非预期应答)→ HTTP 200 `{"available":false}`,**不报错**(面板音频页显示
「audiod 不可用」)。只读端点;不放测试音(真机乱响风险,tone 留给 CLI `meow_audio tone`)。
- **前端**:「音频」页(`web/#/audio`)5s 轮询展示——当前流(app/rate/ch/fmt/已写帧/已播放帧/underrun/暂停态)+
路由口 + 音量 + audiod 可用性;切页即停轮询。
### 5.13 字体管理页(前端 `web/#/fonts`,新增)
- 后端 `/api/fonts*` 三端点(§5.11,agent-243 落地)的专属页面:表格列出 family/weight/name/tier/glyph_count,
行内「导出」(busy 防连点「打包中…」)+ 顶部「上传字体库 .tar.gz」(导入中禁用按钮=进度态)+「刷新」。
- 上传遇 **409 CONFLICT**(同 family/weight 已存在)→ 弹确认框,确认后带 `?force=1` 重试;成功 toast 带
`family/weight` 与 `warning`(sha256 不符仅提示)/`hint`。
- 设置页原「字体库」卡保留(同一后端,互不影响)。
---
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
@@ -345,6 +367,10 @@ http://<host>.local:8080/
上限 32MB,`?force=1` 覆盖)/`/api/fonts/export`(导出,GET,`<family>-<weight>.font.tar.gz`)。前两者由前端设置页「字体库」卡接入。
还修正了 `/api/files` 排序:**目录优先 + 名称自然排序**(数字感知 track2<track10,中文按无符号字节序,见 §5.4)。
**导入不触发 launcher 重扫**(扫描时机=启动/进设置页),响应用 `hint` 提示。
14. **`/api/audio`(新增,§5.12)**:协议 §2 无此端点。按 AUDIOD_DESIGN §8「MSG_AUDIO_STATE 供 MeowPanel 系统/音频页展示」落地——
连 audiod sock 发 `MSG_AUDIO_QUERY`(0x00C7)、收 `MSG_AUDIO_STATE`(0x00C8) k=v 文本解析为 JSON 透传;**audiod 无握手**、
不在时回 `{"available":false}`(200,非错误)。前端新增「音频」页(5s 轮询只读)。同批新增前端「字体」页(§5.13,
`/api/fonts*` 的专属页;设置页字体库卡保留)。
---
@@ -392,17 +418,17 @@ http://<host>.local:8080/
```
patch_v2/meowpanel/
├── meowpanel.c # 后端源码(约 4560 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序)
├── meowpanel.c # 后端源码(约 4650 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序/audiod 状态快照)
├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm,-Wall -Wextra 0 warning)
├── mp_jpeg.h # mp_jpeg.c 接口(RGB565 → 内存 JPEG)
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;meowpanel.c + mp_jpeg.c)
├── meowpanel # 交叉产物(mipsel 静态,约 218 KB,md5 51ffc183…)
├── meowpanel # 交叉产物(mipsel 静态,约 218 KB,md5 015f9cf1…)
├── meowpanel_host # 宿主编译(本机 ld,调试用)
├── services.d/
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
├── web/ # 前端(已存在;部署时拷到后端同目录)
│ ├── index.html # 含登录页 overlay + 设置密码对话框
│ ├── index.html # 含登录页 overlay + 设置密码对话框 + 音频/字体导航
│ ├── meowpanel.css # 含登录页样式
│ └── meowpanel.js # API 客户端带 token/401 登录、设置页鉴权卡片
│ └── meowpanel.js # API 客户端带 token/401 登录、设置页鉴权卡片、音频页(5s轮询)/字体页
└── README.md # 本文件
```
Binary file not shown.
+75 -1
View File
@@ -92,6 +92,7 @@ static const char *g_notif_log = NULL; /* 通知转储日志:<card>/notificat
#define PERF_SOCK "/usr/data/meow_perf.sock"
#define POWER_SOCK "/usr/data/meow_power.sock"
#define LAUNCHER_SOCK "/usr/data/meow_ipc.sock" /* launcher 主 IPC(通知活跃集查询/撤清) */
#define AUDIO_SOCK "/usr/data/meow_audio.sock" /* meow_audiod 状态快照(MSG_AUDIO_QUERY→STATE) */
#define NYAI_PATH "/usr/bin/nyai" /* 固件内置(权威) */
#define NYAI_PATH_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai" /* 旧卡上路径(回退) */
#define BOOT_DEFAULT_FILE "/data/meow_boot_default"
@@ -123,6 +124,7 @@ static const char *g_sensitive_names[] = {
static const char *g_sock_perf = PERF_SOCK;
static const char *g_sock_power = POWER_SOCK;
static const char *g_sock_launcher = LAUNCHER_SOCK;
static const char *g_sock_audio = AUDIO_SOCK;
/* ---------------- 增量帧缓存(GET /api/screen.jpeg?delta=1,阶段 C 增强) ----------------
* 服务端缓存上一帧紧凑 RGB565,按 8×8 块比对,只编码变化块(外接 bbox 子图)并以响应头回传偏移。
@@ -908,6 +910,72 @@ done:
return rc;
}
/* ---------------- 音频状态:meow_audiod IPC(MSG_AUDIO_QUERY→MSG_AUDIO_STATE) ----------------
* audiod 无 MSG_HELLO 握手(见 patch_v2/meow_audiod/meow_audio.c CLI:直连发空负载 QUERY,
* 收 STATE 文本帧——"k=v" 空格分隔、NUL 结尾)。audiod 不在/超时/应答异常一律回
* {"available":false}(HTTP 200,不报错)——面板音频页据此显示「audiod 不可用」。
* socket 可被 env MEOW_AUDIO_SOCK / --sock-audio 覆盖(宿主/qemu 测试用,与 audiod CLI 同 env)。 */
static const char *audio_kv(const char *txt, const char *key, char *out, size_t cap)
{
size_t kl = strlen(key);
const char *p = txt;
while (p && *p) {
if (!strncmp(p, key, kl) && p[kl] == '=') {
p += kl + 1;
size_t n = strcspn(p, " \t\n");
if (n >= cap) n = cap - 1;
memcpy(out, p, n);
out[n] = 0;
return out;
}
p = strchr(p, ' ');
if (p) p++;
}
out[0] = 0;
return NULL;
}
static long long audio_kv_ll(const char *txt, const char *key, long long defv)
{
char b[64];
if (!audio_kv(txt, key, b, sizeof b) || !b[0]) return defv;
return strtoll(b, NULL, 10);
}
static int api_audio(int sock)
{
int fd = ipc_connect_tmo(g_sock_audio, IPC_TIMEOUT_MS);
if (fd < 0) return send_json(sock, 200, "{\"available\":false}");
uint8_t buf[MEOW_IPC_MAX];
uint16_t type = 0; uint32_t rid = 0;
int n = -1;
if (send_frame(fd, MSG_AUDIO_QUERY, 5, NULL, 0) == 0)
n = recv_frame(fd, &type, &rid, buf, sizeof buf - 1);
close(fd);
if (n <= 0 || type != MSG_AUDIO_STATE)
return send_json(sock, 200, "{\"available\":false}");
buf[n] = 0;
const char *txt = (const char *)buf;
char app[MEOW_APP_ID_MAX + 8], fmt[32], rmode[16];
audio_kv(txt, "app", app, sizeof app);
audio_kv(txt, "fmt", fmt, sizeof fmt);
audio_kv(txt, "route_mode", rmode, sizeof rmode);
sbuf_t s = {0};
sA(&s, "{\"available\":true,\"active\":%lld,\"app\":", audio_kv_ll(txt, "active", 0));
sA_str(&s, app);
sA(&s, ",\"sid\":%lld,\"rate\":%lld,\"ch\":%lld,\"fmt\":",
audio_kv_ll(txt, "sid", 0), audio_kv_ll(txt, "rate", 0), audio_kv_ll(txt, "ch", 0));
sA_str(&s, fmt);
sA(&s, ",\"written_frames\":%lld,\"played_frames\":%lld,\"underrun\":%lld,\"paused\":%lld,\"route_mode\":",
audio_kv_ll(txt, "written", 0), audio_kv_ll(txt, "played", 0),
audio_kv_ll(txt, "underrun", 0), audio_kv_ll(txt, "paused", 0));
sA_str(&s, rmode);
sA(&s, ",\"port\":%lld,\"vol\":%lld}",
audio_kv_ll(txt, "port", -1), audio_kv_ll(txt, "vol", -1));
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
/* ---------------- 路径安全 ---------------- */
static int tf_present(void)
{
@@ -4385,6 +4453,9 @@ static int handle(int sock, req_t *r)
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/fonts")) return api_fonts_list(sock);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/fonts/export")) return api_fonts_export(sock, r);
/* ---- 音频状态(meow_audiod 快照,只读) ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/audio")) return api_audio(sock);
/* ---- 实时画面(阶段 C) ---- */
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/screen.jpeg"))
return api_screen_jpeg(sock, r);
@@ -4513,10 +4584,11 @@ int main(int argc, char **argv)
else if (!strcmp(argv[i], "--sock-perf") && i + 1 < argc) g_sock_perf = argv[++i];
else if (!strcmp(argv[i], "--sock-power") && i + 1 < argc) g_sock_power = argv[++i];
else if (!strcmp(argv[i], "--sock-launcher") && i + 1 < argc) g_sock_launcher = argv[++i];
else if (!strcmp(argv[i], "--sock-audio") && i + 1 < argc) g_sock_audio = argv[++i];
else if (!strcmp(argv[i], "--sock") && i + 1 < argc) { g_sock_perf = argv[++i]; g_sock_power = g_sock_perf; }
else if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) {
printf("MeowPanel backend %s\n", PANEL_VERSION);
printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P] [--sock-launcher P]\n");
printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P] [--sock-launcher P] [--sock-audio P]\n");
return 0;
}
}
@@ -4527,6 +4599,8 @@ int main(int argc, char **argv)
if (env && env[0]) g_sock_power = env;
env = getenv("MEOW_IPC_LAUNCHER_SOCK");
if (env && env[0]) g_sock_launcher = env;
env = getenv("MEOW_AUDIO_SOCK");
if (env && env[0]) g_sock_audio = env; /* 与 audiod CLI 同 env(宿主/qemu 测试用) */
env = getenv("MEOW_SETTINGS_FILE");
if (env && env[0]) g_settings_file = env; /* 设置文件覆盖(宿主测试用;设备默认 /usr/data/meow/settings.conf) */
Binary file not shown.
+8
View File
@@ -29,6 +29,10 @@
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/></svg></span>
<span class="nav-label">亮度·音量</span>
</button>
<button class="nav-item" data-route="audio" title="音频服务状态(meow_audiod)">
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M4 10v4h3l5 4V6l-5 4H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
<span class="nav-label">音频</span>
</button>
<button class="nav-item" data-route="files" title="文件管理">
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg></span>
<span class="nav-label">文件</span>
@@ -37,6 +41,10 @@
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="13" y="4" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="4" y="13" width="7" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="13" y="13" width="7" height="7" rx="4" fill="none" stroke="currentColor" stroke-width="2"/></svg></span>
<span class="nav-label">应用</span>
</button>
<button class="nav-item" data-route="fonts" title="字体库管理">
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M5 18L10 5h1.4L16.5 18h-2.2l-1.2-3.2H8.3L7.1 18H5zm4-5h3.4l-1.7-4.7L9 13z" fill="currentColor"/><path d="M17 19h4v1.6h-4z" fill="currentColor"/></svg></span>
<span class="nav-label">字体</span>
</button>
<button class="nav-item" data-route="settings" title="设置镜像">
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="7" cy="18" r="2" fill="var(--bg)"/></svg></span>
<span class="nav-label">设置</span>
+139 -1
View File
@@ -328,6 +328,61 @@
});
}
/* ---------------- 2b. 音频页(meow_audiod 状态快照,5s 轮询,只读) ---------------- */
let audioTimer = null;
function stopAudio() { if (audioTimer) { clearInterval(audioTimer); audioTimer = null; } }
function audioPortName(p) { // audiod numid=9 路由口语义(AUDIOD_DESIGN §5)
const m = { 0: '空闲', 1: 'Line Out', 2: '3.5mm', 3: '4.4mm 平衡', 4: 'Type-C SPDIF' };
if (p == null || p < 0) return '—';
return m[p] || ('端口 ' + p);
}
function fmtFrames(n) { return (n == null) ? '—' : Number(n).toLocaleString(); }
function renderAudioData(page, d) {
let h = '';
if (!d || !d.available) {
h += '<div class="card"><h3 class="card-title">音频服务(meow_audiod)</h3>' +
'<p class="muted">audiod 不可用(服务未运行或 IPC 不可达)。MeowOS 模式下 audiod 恒在;HibyOS 模式下按门控不启动。</p></div>';
} else {
h += '<div class="card"><h3 class="card-title">当前流</h3>';
if (d.active) {
h += '<table class="table"><tbody>' +
row('应用', esc(d.app || '—') + ' <span class="muted">(sid ' + esc(d.sid) + ')</span>') +
row('采样率', esc(d.rate) + ' Hz') +
row('声道', esc(d.ch) + ' ch') +
row('格式', esc(d.fmt || '—')) +
row('已写帧', fmtFrames(d.written_frames)) +
row('已播放帧', fmtFrames(d.played_frames)) +
row('underrun', '<span class="' + (d.underrun > 0 ? 'urgency-hi' : '') + '">' + esc(d.underrun) + '</span>') +
row('状态', d.paused ? '已暂停' : '播放中') +
'</tbody></table>';
} else {
h += '<p class="muted">当前无活动音频流。</p>';
}
h += '</div>';
h += '<div class="card"><h3 class="card-title">路由与音量</h3><table class="table"><tbody>' +
row('路由模式', esc(d.route_mode || '—') + (d.route_mode === 'manual' ? '(手动覆盖)' : '(跟随插孔)')) +
row('输出口', audioPortName(d.port)) +
row('音量', (d.vol != null && d.vol >= 0 ? esc(d.vol) + ' %' : '—')) +
'</tbody></table></div>';
}
h += '<p class="muted">每 5 秒自动刷新 · 只读展示(测试音请用设备 CLI <code>meow_audio tone</code>,面板不放声)。</p>';
page.innerHTML = h;
}
async function fetchAudioOnce() {
const page = $('#audio-page');
if (!page || state.route !== 'audio') return;
let d;
try { d = await api('GET', '/api/audio'); }
catch (e) { if (page && state.route === 'audio') page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
if (state.route === 'audio') renderAudioData(page, d);
}
function renderAudio() {
$('#content').innerHTML = '<div class="page" id="audio-page"><p class="placeholder">加载音频状态…</p></div>';
stopAudio();
fetchAudioOnce();
audioTimer = setInterval(fetchAudioOnce, 5000);
}
/* ---------------- 3. 文件管理页 ---------------- */
// 返回带索引的 rel 显示面包屑
function joinRel(a, b) {
@@ -556,6 +611,86 @@
} catch (e) { toast('获取详情失败:' + errText(e)); }
}
/* ---------------- 4b. 字体管理页(/api/fonts* 专属页) ---------------- */
function renderFontsPage() {
$('#content').innerHTML = '<div class="page" id="fonts-page">' +
'<div class="card"><h3 class="card-title">字体库</h3>' +
'<div class="field-row" style="margin-bottom:14px">' +
'<button class="btn btn-primary" id="fpage-import">上传字体库 .tar.gz</button>' +
'<button class="btn" id="fpage-refresh">刷新</button>' +
'<input type="file" id="fpage-file" accept=".tar.gz,.tgz,application/gzip" hidden></div>' +
'<div id="fpage-list"><p class="muted">加载字体库列表…</p></div>' +
'<p class="muted" style="margin-top:8px">上传 tar.gz(内含 font.png + font.idx + font.json,平铺三件套)→ 校验后解压到 ' +
'<code>MeowOS/fonts/&lt;family&gt;/&lt;weight&gt;/</code>;同 family/weight 冲突时会要求确认覆盖。' +
'导入后进设备「设置 → 字体」页,列表自动重扫生效。</p></div></div>';
loadFontsPage();
}
async function loadFontsPage() {
const box = $('#fpage-list'); if (!box) return;
let d;
try { d = await api('GET', '/api/fonts'); }
catch (e) { box.innerHTML = '<p class="muted">字体库列表加载失败:' + esc(errText(e)) + '</p>'; return; }
const fs = (d && d.fonts) || [];
if (!fs.length) { box.innerHTML = '<div class="empty">暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)</div>'; return; }
let h = '<table class="table"><thead><tr><th>family</th><th>weight</th><th>名称</th><th>tier</th><th>glyph 数</th><th style="text-align:right">操作</th></tr></thead><tbody>';
fs.forEach(f => {
h += '<tr><td class="mono">' + esc(f.family || '') + '</td><td class="mono">' + esc(f.weight || '') + '</td>' +
'<td>' + esc(f.name || '—') + '</td><td class="muted">' + esc(f.tier || '—') + '</td>' +
'<td class="muted">' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + '</td>' +
'<td><div class="row-actions"><button class="btn btn-ghost" data-fpexport="' + esc(f.family || '') +
'" data-fpweight="' + esc(f.weight || '') + '">导出</button></div></td></tr>';
});
box.innerHTML = h + '</tbody></table>';
}
async function importFontPage(files) {
if (!files || !files.length) return;
const f = files[0];
const btn = $('#fpage-import');
if (btn && btn.disabled) return; // 上传中防连点(进度态)
const fd = new FormData(); fd.append('file', f);
if (btn) { btn.disabled = true; btn.textContent = '导入中…'; }
toast('正在导入 ' + f.name + '…');
let res = null;
try { res = await api('POST', '/api/fonts/import', fd); }
catch (err) {
if (err.code === 'CONFLICT') {
const ok = await confirmDlg('覆盖字库', '目标字库(同 family/weight)已存在,覆盖导入吗?', true);
if (ok) {
try { res = await api('POST', '/api/fonts/import?force=1', fd); }
catch (e2) { toast('导入失败:' + errText(e2)); }
}
} else toast('导入失败:' + errText(err));
}
if (btn) { btn.disabled = false; btn.textContent = '上传字体库 .tar.gz'; }
if (res) {
toast('已导入 ' + (res.family || '') + '/' + (res.weight || ''));
if (res.warning) toast(res.warning);
else if (res.hint) toast(res.hint);
loadFontsPage();
}
}
function bindFonts() {
const c = $('#content');
c.addEventListener('click', function (e) {
if (e.target.closest('#fpage-import')) { const inp = $('#fpage-file'); if (inp) { inp.value = ''; inp.click(); } return; }
if (e.target.closest('#fpage-refresh')) { loadFontsPage(); return; }
const fe = e.target.closest('[data-fpexport]');
if (fe) {
if (fe.dataset.busy) return; // 打包期间防连点
fe.dataset.busy = '1';
const old = fe.textContent; fe.textContent = '打包中…';
const f = fe.dataset.fpexport, w = fe.dataset.fpweight;
doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w),
(f || 'font') + '-' + (w || 'regular') + '.font.tar.gz')
.finally(() => { delete fe.dataset.busy; fe.textContent = old; });
return;
}
});
c.addEventListener('change', function (e) {
if (e.target.id === 'fpage-file') importFontPage(e.target.files);
});
}
/* ---------------- 5. 设置镜像页 ---------------- */
function settingControl(it) {
const key = it.key;
@@ -1050,8 +1185,10 @@
const ROUTES = {
perf: { title: '实时性能', render: renderPerf, bind: null },
bright: { title: '亮度 · 音量', render: renderBright, bind: null },
audio: { title: '音频状态', render: renderAudio, bind: null },
files: { title: '文件管理', render: renderFiles, bind: bindFiles },
apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps },
fonts: { title: '字体管理', render: renderFontsPage, bind: bindFonts },
settings: { title: '设置镜像', render: renderSettings, bind: bindSettings },
remote: { title: '远程控制', render: renderRemote, bind: bindRemote },
screen: { title: '实时屏幕', render: renderScreen, bind: bindScreen },
@@ -1070,6 +1207,7 @@
const r = ROUTES[id] || ROUTES.perf;
state.route = id;
stopPerf();
stopAudio();
stopScreenPoll();
$$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id));
$('#page-title').textContent = r.title;
@@ -1089,7 +1227,7 @@
bindNav();
bindThemeUi();
bindSlider();
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif();
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif();
bindLogin();
bindPwDlg();
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用