feat(meowpanel): MeowPanel 后端(HTTP/JSON单进程, 内嵌静态托管, 对接meow_svc_perf/power IPC, 设置镜像, 文件/应用/按键注入) + 前端(蓝牙栏) + 服务配置 + README (agent-164/165)
This commit is contained in:
1 parent
fc0ed4938f
commit
ae51fe9f70
8 files changed
+4277
No files matched your search
@@ -0,0 +1,246 @@
|
||||
# MeowPanel 后端(`meowpanel.c`)
|
||||
|
||||
MeowOS(HiBy R3 Pro II 第三方系统,MIPS32 静态交叉编译)的**外置 Web 面板后端**:单进程、零第三方依赖的
|
||||
C 静态二进制,内嵌 HTTP/JSON 服务器 + 前端静态托管 + 框架专项服务 IPC 客户端 + `settings.conf` 设置镜像。
|
||||
|
||||
- **定位**(`MEOWPANEL_DESIGN.md`):外置于 TF 卡 `MeowOS/opt/MeowPanel/` 的系统组件,由 `meow_svcd`
|
||||
读 TF 卡 `MeowOS/services.d/meowpanel.conf` 启动、`meow_sysui` 监管。**不进固件**;无卡时系统核心
|
||||
(launcher/sdk/设置/通知)绝不依赖它。
|
||||
- **协议**:`devdocs/plan/MEOWPANEL_PROTOCOL.md`(HTTP/JSON 端点;本实现以「实现为准,协议/README 记录出入」)。
|
||||
- **数据路径**(`SYSTEM_FRAMEWORK_SERVICES.md §2/§4`):读侧(`/api/{status,perf,system}`)经 `meow_svc_perf`
|
||||
IPC(`MSG_PERF_QUERY→RESULT`)聚合,**绝不各自爬 /proc**;动作侧(`/api/system/{reboot,shutdown,ota}`)经
|
||||
`meow_svc_power` IPC(`MSG_POWER_ACTION→STATUS`),不再直连 `poweroff`/写 mtd5。两专项服务为**板载 svcd 托管**
|
||||
(`trigger=boot`),拔卡保留,与无卡依赖最小化自洽。
|
||||
|
||||
---
|
||||
|
||||
## 1. 编译
|
||||
|
||||
工具链:交叉编译器 `/home/Aska/Project/R3ProII/.tools/zig-mipsel-cc`(mipsel 32 位、musl 静态,可用)。
|
||||
|
||||
```bash
|
||||
cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
|
||||
./build_meowpanel.sh # 交叉编译 → meowpanel(mipsel 静态,firmware-ready)
|
||||
./build_meowpanel.sh host # 宿主编译 → meowpanel_host(本机 ld,仅供调试)
|
||||
```
|
||||
|
||||
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**。
|
||||
- 产出(本次构建实测):
|
||||
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
|
||||
体积约 **166 KB**(固件阈值内)。md5:`ea1d6a58cbc0d312498d3925e2fd2127`。
|
||||
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **77 KB**。
|
||||
- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。
|
||||
- 头文件引用:`-I patch_v2/meow_launcherd`(`meow_ipc.h` 帧格式 / `m_perf_*`/`m_power_*`/`POWER_*`,
|
||||
**header 为权威源**,结构体字段以它为准)。
|
||||
|
||||
## 2. 部署到 TF 卡
|
||||
|
||||
```bash
|
||||
# 1) 后端 + 前端(web/index.html + meowpanel.css + meowpanel.js)放在同一目录
|
||||
sudo mkdir -p /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel
|
||||
sudo cp patch_v2/meowpanel/meowpanel /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/
|
||||
sudo cp patch_v2/meowpanel/web/index.html /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/
|
||||
sudo cp patch_v2/meowpanel/web/meowpanel.css /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/
|
||||
sudo cp patch_v2/meowpanel/web/meowpanel.js /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/
|
||||
|
||||
# 2) 服务配置(svcd 读 TF 卡 services.d 拉起)
|
||||
sudo mkdir -p /usr/data/mnt/sd_0/MeowOS/services.d
|
||||
sudo cp patch_v2/meowpanel/services.d/meowpanel.conf /usr/data/mnt/sd_0/MeowOS/services.d/
|
||||
```
|
||||
|
||||
说明:
|
||||
- 前端资源与后端**同目录**(后端 `GET /` → `index.html`,`GET /meowpanel.css`/`meowpanel.js` 同目录取)。
|
||||
后端也支持 `GET /assets/<file>`(映射到同目录文件),前端当前用根相对路径。
|
||||
- 服务启动后整卡由 `meow_svcd` 按 `trigger=boot` 拉起;`meow_sysui` 监管(restart/backoff/max_restart)。
|
||||
- **无卡**:`MeowOS/services.d/` 不可读 → svcd 跳过本服务;系统核心照常。
|
||||
|
||||
## 3. 访问
|
||||
|
||||
浏览器(同局域网):
|
||||
```
|
||||
http://<host>.local:8080/
|
||||
```
|
||||
- mDNS 由板载 `meow_mdns` 应答 `<host>.local`。
|
||||
- 端口默认 **8080**,可用 `-p PORT` / 环境变量 `MEOWPORT` / 与后端同目录 `meowpanel.conf`(`port=N`)覆盖。
|
||||
(优先级:`-p` > `MEOWPORT` > `meowpanel.conf` > 默认 8080。)
|
||||
- 无鉴权(设备内网信任模型;token 鉴权为 MEOWPANEL_PROTOCOL §1.3 草案项,未实现)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 端点实现清单(对照 MEOWPANEL_PROTOCOL §2 端点总览)
|
||||
|
||||
### 已实现(返回 200/2xx 或语义错误码)
|
||||
|
||||
| 方法 | 路径 | 说明 | 实现 |
|
||||
|---|---|---|---|
|
||||
| GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/<f>` | 静态托管 | ✅ |
|
||||
| GET | `/api/status` | 性能+系统(`meow_svc_perf` IPC subset=all 透传) | ✅ |
|
||||
| GET | `/api/perf` | 仅性能(subset=perf 透传) | ✅ |
|
||||
| GET | `/api/system` | perf IPC + boot/network/ota 局部补全(见注) | ✅ |
|
||||
| POST | `/api/system/reboot`、`/shutdown`、`/ota` | `meow_svc_power` IPC | ✅ |
|
||||
| GET | `/api/settings` | 全量+元数据(items/count/groups) | ✅ |
|
||||
| GET | `/api/settings/:key` | 单项 | ✅ |
|
||||
| PUT | `/api/settings/:key` | 写单项(clamp/枚举校验) | ✅(越界/非法枚举→422) |
|
||||
| POST | `/api/settings` | 批量写 | ✅ |
|
||||
| POST | `/api/settings/restore` | 恢复默认 | ✅ |
|
||||
| GET | `/api/theme` | 读主题 | ✅ |
|
||||
| PUT | `/api/theme` | 写主题(style/theme/background 子集) | ✅(非法→422) |
|
||||
| GET | `/api/files?path=` | 目录列表(白名单) | ✅(无卡→503) |
|
||||
| GET | `/api/files/read?path=` | 读文本(<1MB;二进制→415) | ✅ |
|
||||
| GET | `/api/files/download?path=` | 下载流 | ✅ |
|
||||
| POST | `/api/files/upload` | multipart 上传 | ✅ |
|
||||
| POST | `/api/files/mkdir` | 新建目录(已存在→409) | ✅ |
|
||||
| POST | `/api/files/rename` | 重命名 | ✅ |
|
||||
| DELETE | `/api/files?path=` | 删除(可 recursive=1;非空未 recursive→409) | ✅ |
|
||||
| GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ |
|
||||
| GET | `/api/apps/:pkg` | 应用详情 | ✅ |
|
||||
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
|
||||
| GET | `/api/apps/:pkg/export` | `nyai export` 下载流 | ✅ |
|
||||
| DELETE | `/api/apps/:pkg` | `nyai remove [--purge]` | ✅ |
|
||||
| POST | `/api/key/:code` | 按键注入(tap/long) | ✅(物理键红线内) |
|
||||
| POST | `/api/key` | 批量/顺序注入 | ✅ |
|
||||
| GET | `/api/logs` | `MeowOS/logs` 文件列表 | ✅(无卡→503) |
|
||||
| GET | `/api/logs/read?file=&lines=` | 读日志(上限 1000 行;`..`/绝对→403;敏感名→403) | ✅ |
|
||||
| GET | `/api/notifications` | **501** | ⚠️ |
|
||||
| DELETE | `/api/notifications/:id` | **501** | ⚠️ |
|
||||
| POST | `/api/notifications/dismiss_all` | **501** | ⚠️ |
|
||||
|
||||
### 返回 501 `NOT_IMPLEMENTED` 的端点(诚实标注)
|
||||
|
||||
| 端点 | 原因 |
|
||||
|---|---|
|
||||
| `/api/notifications`、`/api/notifications/:id`、`/api/notifications/dismiss_all` | 通知存储为 **launcher 进程内存**(`meow_notif.c g_slots[]`,NOTIFICATION_SYSTEM §4),后端为独立 svcd 子进程,**跨进程不可达**那批内存槽;框架只在关机前 dump 到 TF `MeowOS/notifications/notifications.log`(CSV 追加,非活跃快照)。**需为通知后端另开 IPC 查询/清撤通道**(如 `MSG_NOTIFY_LIST` 应答)或持久化活跃集,见 MEOWPANEL_PROTOCOL §6 待定案。 |
|
||||
| `/api/key/:code` 的 `press:"hold"` | `hold` 需常驻持续注入通道(阶段 D),后端未实现;`tap`/`long` 已实现。 |
|
||||
| OTA 升级模式(`/api/system/ota` 带 `url`) | 升级为草案(MEOWPANEL_PROTOCOL §6 待定案);当前 `POST /api/system/ota` 空 body 触发 `meow_svc_power` 的 `POWER_UPGRADE`(写 mtd5 进升级模式),带 `url` 的下载+升级未实现。 |
|
||||
|
||||
> 前端对 `501` 显示「该功能后端尚未实现」(`ok:false,error.code=NOT_IMPLEMENTED`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键实现说明
|
||||
|
||||
### 5.1 HTTP 路由 / JSON 序列化
|
||||
- **单线程 + poll 串行**(面板访问量极小)。`accept` → `read_request`(先读头到 `\r\n\r\n`,**在缓冲区内搜索**头边界,
|
||||
处理「头+body 同段到达」,再按 `Content-Length` 精确读 body)→ `handle` → 写响应 → 关闭连接(`Connection: close`)。
|
||||
- 读/写均有 `poll` 超时(读头部 5s、body 分段 5s,IPC 2s)。`SIGPIPE` 忽略。
|
||||
- JSON 响应信封:成功 `{"ok":true,"data":<payload>}`;失败 `{"ok":false,"error":{"code","message"}}`
|
||||
(MEOWPANEL_PROTOCOL §1.1/§1.2,HTTP 状态码映射照抄)。
|
||||
- **无 JSON 库**:手写最小递归下降解析器(对象/数组/字符串转义/数字/bool/null),仅覆盖实际用到的收发。
|
||||
- 下载/上传用流式(`multipart/form-data` 极简解析:按 boundary 切,取 `path`+`file` 字段)。
|
||||
|
||||
### 5.2 IPC 对接(meow_svc_perf / meow_svc_power)
|
||||
- **帧**:复用 `meow_ipc.h`——16B 头(magic `0x4D4F5701`/version 1/type/req_id/len,全小端)+ 变长负载,
|
||||
`MEOW_IPC_MAX=4096`。帧读写 `x_read_full`/`x_write_full` + `send_frame`/`recv_frame`(与 meow_svc_perf.c 对称)。
|
||||
- **握手**:连接后先 `MSG_HELLO`(`app_id="os.meow.panel"`,系统面板身份,§5 二期拟名)→ 读 `MSG_ACK(code=0)`。
|
||||
- **读侧**:`MSG_PERF_QUERY`(`m_perf_query_t.subset`)→ `MSG_PERF_RESULT`(`m_perf_result_t`,`code`/`json_len`/`json`),
|
||||
**直接透传**服务端聚合 JSON(`/api/status`=全部、`/api/perf`=perf 子集、`/api/system`=全量取 uptime/storage/tfcard/system)。
|
||||
读取防溢出:`json_len` 以 `sizeof(m_perf_result_t.json)-1` 与 `cap` 双钳位。
|
||||
- **动作侧**:`MSG_POWER_ACTION`(`m_power_action_t.cmd=POWER_REBOOT/SHUTDOWN/UPGRADE`)→ `MSG_POWER_STATUS`(`code`/`msg`)。
|
||||
收到 `MEOW_OK` → `{"accepted":true}`;否则按 `code`(PERM→403 / NOENT/IO→503 / 其余→500)返回错误信封。
|
||||
- **失败语义**:socket 不存在/连接拒/超时/应答非 OK → 返回 500 `INTERNAL`(或 503 `NO_CARD` 当缺升级文件)
|
||||
并带中文 message。**绝不静默失败、绝不降级直连 /proc 或 poweroff**(单一权威源)。
|
||||
- 连接超时:非阻塞 connect + `poll(POLLOUT)` 2s,`SO_RCVTIMEO`/`SO_SNDTIMEO` 兜底。
|
||||
|
||||
### 5.3 设置镜像(读写板载 `settings.conf`)
|
||||
- **不旁路**:只经 `settings_get_value`(`=` 切分 + key 去首尾空白精确匹配,防 `audio.volume` 误配 `audio.volume_max`,
|
||||
照 `meow_ui_vol_read`/`meow_svc_power settings_get_int_trim` 同款解析)与 `settings_write_one`(`"key = value\n"`,
|
||||
保留 `# meow-settings 1` 头、逐行重写、缺键追加;目录缺失自动 `mkdir`)。**不绕过此函数直接改文件**。
|
||||
- **键表/默认值来源**:`patch_v2/meow_launcherd/meow_settings.c` 的 `g_defs[]`(**权威来源**,逐键一致),
|
||||
含 type/def/min/max/enum_names/enum_labels;`GET /api/settings` 返回 `items/count/groups`(分组按键前缀,
|
||||
如 `display.*`→显示、`font.*|ui.*`→字体主题…)。
|
||||
- 类型:`int`→number(带 min/max/def 字符串)、`bool`→原生布尔、`enum`→`options(name/label)+default_name+def`、
|
||||
`str`→string。**越界/非法枚举 → 422 `UNSUPPORTED`**。
|
||||
- 语义 clamp:`display.brightness` 读/写都 clamp 到 `[display.brightness_min(默认1), 100]`(防 101 闪屏,
|
||||
与 `meow_settings_brightness_clamped`/`clamp_brightness` 同口径);`boot.default` 写后同步 `/data/meow_boot_default`
|
||||
(meow→0/hiby→1,同 `meow_settings_save`)。
|
||||
- 注:后端为独立进程,无法调用 launcher 进程内的 `meow_settings_*`(那是其内存表),故**直接镜像同一份
|
||||
`settings.conf`**,保持与实体设置页/CLI 同一存储契约(协议 §3.2 说「经 meow_settings_*」,此处以
|
||||
「镜像同一文件 + 同款 parse/save 契约」落地,见 §8 出入记录)。
|
||||
|
||||
### 5.4 文件白名单
|
||||
- 根 `/usr/data/mnt/sd_0`;白名单子目录 `MeowOS/Music/MeowData/MeowAPPs/book`(`allowed` 数组)。
|
||||
- 路径经 `normalize_path` 规范化(去 `.`/`..`,拒绝逃逸出根)后 `path_in_whitelist` 校验(== 根或属于某一白名单
|
||||
一级子目录)。**`..`/绝对路径逃逸 → 403 `FORBIDDEN`**;无卡 → 503 `NO_CARD`。
|
||||
- 读取拒绝敏感名(`settings.conf`/`setting.bak`/`.env`/`passwd`/`shadow`/`meow.conf`/`user.ini`/`config.json`)。
|
||||
- 列表/上传/下载/读文本(1MB+二进制→415)/mkdir(已存在→409)/rename(new_name 不含 `/`)/delete(递归,非空未 recursive→409)。
|
||||
|
||||
### 5.5 nyai 对接
|
||||
- 包管理器 CLI 绝对路径 `/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai`(M2.12+ 固件经 `meow_card_path.sh` 前插 PATH,可直接调;
|
||||
本后端用绝对路径,兼容更早固件)。包名 = `app.json` 的 `package` = `.mpk` 文件名茎。
|
||||
- `install`:multipart 上传 `.mpk` 落临时文件(`MeowOS/tmp/`)或 JSON `{path}` → `nyai install <file>`;用 `popen` 捕获输出+退出码,
|
||||
非 0 → 422。`export`:`nyai export <pkg> <tmp>` → 以 `Content-Disposition: attachment` 流式下发。`remove`:`nyai remove <pkg> [--purge]`。
|
||||
- `GET /api/apps` / `:pkg` 详情:直接扫描 `MeowAPPs/<dir>/app.json`(手写 JSON 解析提取 name/version/exec/framework/immersive/fullscreen),
|
||||
不依赖 nyai(nyai 未部署时列表/详情仍可用)。解析失败项 `invalid:true`。
|
||||
- `nyai` 未部署 → install/export/remove 返回 500(带 nyai 输出)。
|
||||
|
||||
### 5.6 按键注入通道
|
||||
- **复用现有先例**:`patch_v2/uinput_tap/uinput_tap.c` 的 `write`-到-evdev 注入机制。launcher 实际轮询
|
||||
`meow_launcher_internal.h`:`/dev/input/event0`(md-gpio-keys: POWER/PREVIOUS)、`/dev/input/event2`
|
||||
(jz adc keyboard: VOL/PLAY/NEXT)。后端 open `O_WRONLY` 写 `EV_KEY press/release + SYN_REPORT` 即可到达 launcher 按键处理。
|
||||
- **物理键红线**(APPS.md / 任务约束):只注入音量(114/115)/电源(116)/播放(164)/上下曲(163/165);
|
||||
**禁止注入 返回(158)/主页(172)/确认(28)/退出(96)** → 403 `FORBIDDEN`。
|
||||
- `tap`(默认,抬起间隔 80ms)、`long`(按住 ms,缺省 3000);`hold` → 501。
|
||||
- 局限:若 `/dev/input/event0/event2` 在设备上不可写(权限/路径差异),注入失败 → 500;注入为**即时性注入**,
|
||||
不在 launcher 内维护长按状态机(与实体按键的 `longpressed` 语义近似,见 README 已知边界)。
|
||||
|
||||
### 5.7 日志
|
||||
- `GET /api/logs`:`MeowOS/logs` 下常规文件列表(跳过敏感名)。
|
||||
- `GET /api/logs/read?file=&lines=`:`file` 必须相对 `MeowOS/logs/`,**禁 `..`/绝对路径 → 403**,敏感名 → 403;
|
||||
缺省 `lines=200`,上限 `1000`;返回 `{file,lines[],total,truncated}`。
|
||||
|
||||
### 5.8 通知(501 及其依据)
|
||||
见 §4 501 表:通知存储为 launcher 进程内存,后端跨进程不可读/清撤。已在源码 `api_notifications*` 注释「为何未实现 +
|
||||
后续如何接」(通知后端加 `MSG_NOTIFY_LIST` 查询/清撤 IPC,或持久化活跃集到 TF `MeowOS/notifications/` 供只读)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
|
||||
|
||||
1. **`ui.style` 值域**:`meow_settings.c g_defs` 源码为 `"md3,miui"`(label "Material You,MIUI"),但
|
||||
SYSTEM_SETTINGS.md 定案 + MEOWPANEL_PROTOCOL §5 一致行为 **`miuix`**(对应 `MEOW_STYLE_MIUIX`),前端 `data-style` 也用 `miuix`。
|
||||
→ 本表对齐为 `"md3,miuix"` / "Material You,MIUIx"。**建议**同步改回 `meow_settings.c g_defs`(否则实体设置页与面板值域漂移)。
|
||||
2. **设置项映射**:协议 §3.2 称后端经 `meow_settings_*` 读写;因后端为独立进程,无法链接 launcher 内 API,
|
||||
**改为直接镜像同一份 `settings.conf`**(同款 parse/save 契约)。改哪边都会落同一文件,实体/网页/CLI 仍同步(listener/落盘)。
|
||||
3. **`display.brightness` 元数据**:`g_defs` 为 `min=0 max=255 def=101`;但语义 clamp 到 `[brightness_min(1),100]`
|
||||
(防 101 闪屏)。后端给前端 `min=1 max=100`(协议 §3.2 期望),读值经 clamp 后返回(默认→100)。若回改协议可注明「原始表 0-255,面板展示语义 1-100」。
|
||||
4. **`/api/system` 的 `network`/`ota`**:由性能服务聚合(`SYSTEM_FRAMEWORK_SERVICES §4.1`)后,后端只补
|
||||
`boot.default`(读 `/data/meow_boot_default`)、`network.hostname_local`(hostname+.local)与 `ota{current=version,available:false,url:null}`;
|
||||
**wifi/ethernet/ota.available 不编造**(服务未聚合,前端对缺失字段显示占位)。若回改协议可注明「network/ota 待并入 perf 服务」。
|
||||
5. **`/api/files` 上传/删除/重命名等 path**:前端对这些操作传**绝对路径**(`d.path/子项`),列表传**相对 root**;
|
||||
后端 `normalize_path` 对两者均容错(相对则拼 root,绝对则直用),符合协议「白名单根 + 规范化」。
|
||||
6. **`GET /api/files` 返回值**:`files_list` 返回 `500` 语义时以 500 封装(协议未定义 `root` 之外错误枚举,用通用错误信封)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知边界 / 风险
|
||||
|
||||
- **单线程串行**:面板访问量极小,但并发请求会被排队(长上传/下载期间其它请求等待)。对面板用途可接受。
|
||||
- **无鉴权**:设备内网信任模型(协议 §1.3 token 鉴权待定案)。**高危端点**(`reboot/shutdown/ota/apps/删除/上传)无鉴权,
|
||||
请勿把 8080 暴露到不可信网络。`meow_svc_power` 服务端做 `SO_PEERCRED uid==0` 基础校验(root 才受理动作),后端以 root 运行可过;launcher 电源菜单/后端同链路。
|
||||
- **IPC 服务未起**:`meow_svc_perf`/`meow_svc_power` 为板载 `trigger=boot` 服务,正常恒在;若异常退出,读侧返回 500 `INTERNAL`,
|
||||
动作侧返回 500/503,绝不静默。请勿拔除这两服务。
|
||||
- **升级保护**:`POWER_UPGRADE` 由 `meow_svc_power` 在写 mtd5 前校验卡根 `r3proii.upt`(存在/可读/非空/size>0),失败不写 mtd5;期望 md5/hash 校验留二期。
|
||||
- **文件/日志/应用均依赖 TF 卡**:无卡统一 503 `NO_CARD`(不阻塞系统核心)。`settings`/`system`/`perf`/`key` 无卡也可用(板载/rootfs)。
|
||||
- **`/api/files/upload` 上传路径**:multipart 需要 `path` 字段(目标目录);缺失时上传文件会被忽略(前端总是带着 `path`)。
|
||||
- **数据一致性**:设置直接写 `settings.conf`;`display.brightness`/`boot.default` 的 clamp/同步与 launcher `meow_settings_*` 同口径。
|
||||
若 launcher 未来改 clamp 逻辑,需同步本表。
|
||||
- **存储为 `meowpanel` 编译时刻写死**的 socket/路径会因 `--sock-perf/--sock-power`(qemu sim)可覆盖,但默认走 `/usr/data/meow_*.sock`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 文件清单(本目录)
|
||||
|
||||
```
|
||||
patch_v2/meowpanel/
|
||||
├── meowpanel.c # 后端源码(单文件,约 2700 行)
|
||||
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning)
|
||||
├── meowpanel # 交叉产物(mipsel 静态,165 KB,md5 a8224ef9…)
|
||||
├── meowpanel_host # 宿主编译(本机 ld,调试用)
|
||||
├── services.d/
|
||||
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
|
||||
├── web/ # 前端(已存在;部署时拷到后端同目录)
|
||||
│ ├── index.html
|
||||
│ ├── meowpanel.css
|
||||
│ └── meowpanel.js
|
||||
└── README.md # 本文件
|
||||
```
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# build_meowpanel.sh — MeowPanel 后端交叉编译(mipsel 静态)+ 宿主编译可选。
|
||||
#
|
||||
# 目标:R3 Pro II / MeowOS(mipsel-linux-muslsf,静态)。后端仅 POSIX + Linux 基接口
|
||||
# (复用 meow_ipc.h 帧格式,无 launcher 依赖),单文件满足「卡上、静态、零第三方依赖」。
|
||||
#
|
||||
# 用法:
|
||||
# ./build_meowpanel.sh # 交叉编译 → patch_v2/meowpanel/meowpanel(mipsel 静态)
|
||||
# ./build_meowpanel.sh host # 宿主编译 → patch_v2/meowpanel/meowpanel_host
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
ROOT="$HOME/Project/R3ProII"
|
||||
CC="$ROOT/.tools/zig-mipsel-cc"
|
||||
I_HDR="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(帧/m_perf_query_t/m_perf_result_t/POWER_*)
|
||||
|
||||
if [ "$1" = "host" ]; then
|
||||
HOST_CC="${CC_HOST:-cc}"
|
||||
"$HOST_CC" -Os -Wall -Wextra -I"$I_HDR" meowpanel.c -o meowpanel_host
|
||||
echo "== host build =="
|
||||
ls -la meowpanel_host
|
||||
file meowpanel_host
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"$CC" -Os -Wall -Wextra -static -I"$I_HDR" meowpanel.c -o meowpanel
|
||||
echo "== meowpanel (mipsel static) =="
|
||||
ls -la meowpanel
|
||||
md5sum meowpanel
|
||||
file meowpanel
|
||||
Executable
BIN
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
# meowpanel.conf — MeowPanel 后端服务配置(外置 TF 卡,meow_svcd 托管)。
|
||||
# 部署位置:TF 卡 `/usr/data/mnt/sd_0/MeowOS/services.d/meowpanel.conf`
|
||||
# (svcd 双源:板载 /usr/data/meow/services.d + TF MeowOS/services.d,同名板载优先;无卡跳过该服务)。
|
||||
# 字段对齐 meow_svcd 服务配置样例(SYSTEM_FRAMEWORK_SERVICES §1.3 / meow_svcd.md)。
|
||||
#
|
||||
# 定位:MEOWPANEL_DESIGN §1/§2 —— MeowPanel 是**外置系统组件**,整体走卡(MeowOS/opt/MeowPanel),
|
||||
# 不进固件;无卡时 svcd 跳过本服务,系统核心(launcher/sdk/设置/通知)绝不对它依赖。
|
||||
#
|
||||
# 注意:本服务在卡上,端口监听 8080(可用 -p / MEOWPORT / 同目录 meowpanel.conf 的 port=N 覆盖)。
|
||||
|
||||
exec=/usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/meowpanel
|
||||
args=
|
||||
enabled=1
|
||||
restart=always
|
||||
backoff_ms=2000
|
||||
max_restart=5
|
||||
trigger=boot
|
||||
cap=
|
||||
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-style="md3" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>MeowPanel · MeowOS 面板</title>
|
||||
<link rel="stylesheet" href="meowpanel.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 顶部横幅:后端未连接 / 网络错误 -->
|
||||
<div id="offline-banner" class="offline-banner" hidden>⚠️ 后端未连接 — 请确认设备上的 MeowPanel 后端已启动(<code>http://<host>.local:8080</code>)</div>
|
||||
|
||||
<div id="app" class="app">
|
||||
|
||||
<!-- 左侧导航(宝塔式) -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">M</span>
|
||||
<span class="brand-name">MeowPanel</span>
|
||||
</div>
|
||||
<nav class="nav" id="nav">
|
||||
<button class="nav-item" data-route="perf" title="实时性能">
|
||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M3 13h4l2-5 3 9 2-4h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="nav-label">性能</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="bright" title="亮度 / 音量">
|
||||
<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="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>
|
||||
</button>
|
||||
<button class="nav-item" data-route="apps" title="MeowAPPs 管理">
|
||||
<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="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>
|
||||
</button>
|
||||
<button class="nav-item" data-route="remote" title="远程控制">
|
||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="7" y="3" width="10" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="15" r="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M10 7h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||
<span class="nav-label">远程</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="system" title="系统信息 / 升级">
|
||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="6" y="3" width="12" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M9 8h6M9 12h6M9 16h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||
<span class="nav-label">系统</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="logs" title="系统日志">
|
||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M5 4h14v16l-2-2-2 2-2-2-2 2-2-2-2 2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M8 9h8M8 13h5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||
<span class="nav-label">日志</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="notif" title="通知中心">
|
||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M6 9a6 6 0 0 1 12 0v4l2 3H4l2-3z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M10 20a2 2 0 0 0 4 0" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||
<span class="nav-label">通知</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧:顶栏 + 内容 -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<button class="icon-btn menu-btn" id="menu-btn" title="展开/收起导航" aria-label="菜单">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<h1 class="topbar-title" id="page-title">实时性能</h1>
|
||||
<div class="topbar-actions">
|
||||
<span class="conn" id="conn-dot" data-state="unknown" title="后端连接">
|
||||
<span class="conn-dot"></span><span class="conn-text" id="conn-text">检测中…</span>
|
||||
</span>
|
||||
<div class="seg" id="style-seg" title="风格">
|
||||
<button class="seg-btn" data-style="md3">MD3</button>
|
||||
<button class="seg-btn" data-style="miuix">MIUIx</button>
|
||||
</div>
|
||||
<div class="seg" id="theme-seg" title="明暗">
|
||||
<button class="seg-btn" data-theme="dark">深色</button>
|
||||
<button class="seg-btn" data-theme="light">浅色</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content" id="content">
|
||||
<p class="placeholder">加载中…</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通用确认弹窗 -->
|
||||
<div id="modal" class="modal-scrim" hidden>
|
||||
<div class="modal">
|
||||
<h2 class="modal-title" id="modal-title">确认</h2>
|
||||
<p class="modal-body" id="modal-body"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" id="modal-no">取消</button>
|
||||
<button class="btn btn-primary btn-danger" id="modal-yes">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<script src="meowpanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,311 @@
|
||||
/* MeowPanel — 前端样式
|
||||
* 主题模型:风格族(ui.style: md3 / miuix)× 明暗(display.theme: dark / light)
|
||||
* 双层变量:
|
||||
* [data-style] 定形状(radius / spacing / outline 宽)+ 主色(--primary)——对齐 SDK §8.1.3
|
||||
* [data-theme] 定中性色 / 语义色(bg/surface/on-surface/outline/error/...)
|
||||
* 默认(:root 无覆盖)= MD3 dark;MD3 中立形状再显式声明便于对照。
|
||||
*/
|
||||
|
||||
/* ---------- 结构基线 ---------- */
|
||||
:root{
|
||||
--space:8px;
|
||||
--radius-s:8px; /* 小:chip/内嵌 */
|
||||
--radius-m:12px; /* 中:按钮/输入 */
|
||||
--radius-l:16px; /* 大:卡片 */
|
||||
--outline-w:0px; /* MD3 无描边 */
|
||||
--nav-w:212px;
|
||||
--topbar-h:56px;
|
||||
--font: -apple-system, "Segoe UI", "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
||||
--mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", Consolas, monospace;
|
||||
--speed:.16s;
|
||||
}
|
||||
|
||||
/* 风格族:MIUIx 更大圆角 / 略大间距 / 细描边 / 偏品牌主色(更饱和) */
|
||||
:root[data-style="miuix"]{
|
||||
--space:10px;
|
||||
--radius-s:12px;
|
||||
--radius-m:18px; /* 按钮 ≈18px */
|
||||
--radius-l:24px; /* 卡片 ≈24px */
|
||||
--outline-w:1px; /* 细描边 */
|
||||
}
|
||||
|
||||
/* ---------- 深色(默认主深色) ---------- */
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0a0a0a; /* 背景底(黑)*/
|
||||
--surface:#141414;
|
||||
--surface-variant:#1e1e1e;
|
||||
--surface-dim:#000000;
|
||||
--on-surface:#ececec;
|
||||
--on-surface-variant:#a3a3a3;
|
||||
--outline:#3a3a3a;
|
||||
--outline-variant:#2a2a2a;
|
||||
--error:#cf6679; --on-error:#0a0a0a;
|
||||
--success:#4caf50; --warning:#ffb300;
|
||||
--shadow:0 2px 10px rgba(0,0,0,.5);
|
||||
--scrim:rgba(0,0,0,.55);
|
||||
--hover:rgba(255,255,255,.06);
|
||||
}
|
||||
/* 深色 × MIUIx 主色更饱和 */
|
||||
:root[data-theme="dark"][data-style="md3"]{ --primary:#4a8bf5; --on-primary:#fff; }
|
||||
:root[data-theme="dark"][data-style="miuix"]{--primary:#ff7a00; --on-primary:#fff; }
|
||||
|
||||
/* ---------- 浅色 ---------- */
|
||||
:root[data-theme="light"]{
|
||||
--bg:#f4f5f7;
|
||||
--surface:#ffffff;
|
||||
--surface-variant:#ececee;
|
||||
--surface-dim:#e1e1e4;
|
||||
--on-surface:#1b1b1d;
|
||||
--on-surface-variant:#5c5c60;
|
||||
--outline:#d2d2d6;
|
||||
--outline-variant:#e4e4e8;
|
||||
--error:#ba1a1a; --on-error:#ffffff;
|
||||
--success:#2e7d32; --warning:#ed6c02;
|
||||
--shadow:0 2px 10px rgba(0,0,0,.12);
|
||||
--scrim:rgba(0,0,0,.35);
|
||||
--hover:rgba(0,0,0,.05);
|
||||
}
|
||||
:root[data-theme="light"][data-style="md3"]{ --primary:#2f6fe0; --on-primary:#fff; }
|
||||
:root[data-theme="light"][data-style="miuix"]{--primary:#f57200; --on-primary:#fff; }
|
||||
|
||||
/* ---------- 布局 ---------- */
|
||||
*{ box-sizing:border-box; }
|
||||
html,body{ height:100%; margin:0; }
|
||||
body{
|
||||
background:var(--bg); color:var(--on-surface);
|
||||
font-family:var(--font); font-size:14px; line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.app{ display:flex; height:100vh; overflow:hidden; }
|
||||
|
||||
/* 侧栏 */
|
||||
.sidebar{
|
||||
width:var(--nav-w); flex:0 0 var(--nav-w);
|
||||
background:var(--surface); border-right:1px solid var(--outline-variant);
|
||||
display:flex; flex-direction:column; transition:margin-left var(--speed) ease;
|
||||
}
|
||||
.brand{ display:flex; align-items:center; gap:10px; padding:16px 18px; }
|
||||
.brand-mark{
|
||||
width:30px; height:30px; border-radius:var(--radius-m);
|
||||
background:var(--primary); color:var(--on-primary);
|
||||
display:flex; align-items:center; justify-content:center; font-weight:700;
|
||||
}
|
||||
.brand-name{ font-weight:600; font-size:15px; }
|
||||
.nav{ flex:1; overflow-y:auto; padding:8px 10px; display:flex; flex-direction:column; gap:2px; }
|
||||
.nav-item{
|
||||
display:flex; align-items:center; gap:12px; padding:10px 12px;
|
||||
border:none; background:transparent; color:var(--on-surface-variant);
|
||||
border-radius:var(--radius-m); cursor:pointer; text-align:left;
|
||||
font:inherit; font-size:14px; transition:background var(--speed), color var(--speed);
|
||||
}
|
||||
.nav-item:hover{ background:var(--hover); color:var(--on-surface); }
|
||||
.nav-item.active{ background:var(--primary); color:var(--on-primary); }
|
||||
.nav-ico{ width:22px; height:22px; flex:0 0 22px; }
|
||||
.nav-ico svg{ width:100%; height:100%; display:block; }
|
||||
.nav-label{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
/* 主区 */
|
||||
.main{ flex:1; display:flex; flex-direction:column; min-width:0; }
|
||||
.topbar{
|
||||
height:var(--topbar-h); flex:0 0 var(--topbar-h);
|
||||
display:flex; align-items:center; gap:12px; padding:0 16px;
|
||||
background:var(--surface); border-bottom:1px solid var(--outline-variant);
|
||||
}
|
||||
.topbar-title{ font-size:17px; font-weight:600; margin:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.icon-btn{
|
||||
width:38px; height:38px; border:none; border-radius:var(--radius-m);
|
||||
background:transparent; color:var(--on-surface-variant); cursor:pointer;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.icon-btn:hover{ background:var(--hover); color:var(--on-surface); }
|
||||
.icon-btn svg{ width:22px; height:22px; }
|
||||
.menu-btn{ display:none; }
|
||||
|
||||
/* 顶栏分段控件(风格/明暗) */
|
||||
.seg{ display:inline-flex; background:var(--surface-variant); border-radius:var(--radius-m); padding:3px; }
|
||||
.seg-btn{
|
||||
border:none; background:transparent; color:var(--on-surface-variant);
|
||||
padding:6px 12px; border-radius:calc(var(--radius-m) - 2px);
|
||||
cursor:pointer; font:inherit; font-size:13px; white-space:nowrap;
|
||||
transition:background var(--speed), color var(--speed);
|
||||
}
|
||||
.seg-btn.active{ background:var(--surface); color:var(--on-surface); box-shadow:var(--shadow); }
|
||||
|
||||
/* 连接状态点 */
|
||||
.conn{ display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--radius-m); background:var(--surface-variant); color:var(--on-surface-variant); font-size:12px; }
|
||||
.conn-dot{ width:9px; height:9px; border-radius:50%; background:var(--on-surface-variant); }
|
||||
.conn[data-state="ok"] .conn-dot{ background:var(--success); }
|
||||
.conn[data-state="bad"] .conn-dot{ background:var(--error); }
|
||||
.conn[data-state="ok"]{ color:var(--success); }
|
||||
.conn[data-state="bad"]{ color:var(--error); }
|
||||
|
||||
/* 内容区 */
|
||||
.content{ flex:1; overflow-y:auto; padding:20px; }
|
||||
.page{ max-width:1100px; margin:0 auto; }
|
||||
.placeholder{ color:var(--on-surface-variant); text-align:center; padding:60px 0; }
|
||||
|
||||
/* 卡片 */
|
||||
.card{
|
||||
background:var(--surface); border-radius:var(--radius-l);
|
||||
border:var(--outline-w) solid var(--outline); /* MD3 无描边;MIUIx 1px */
|
||||
box-shadow:var(--shadow); padding:18px 20px; margin-bottom:16px;
|
||||
overflow-x:auto; /* 窄屏表格/内容横向滚动,避免整页外溢 */
|
||||
}
|
||||
.card-title{ font-size:15px; font-weight:600; margin:0 0 14px; }
|
||||
|
||||
/* 统计格子(性能页) */
|
||||
.stat-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; }
|
||||
.stat{
|
||||
background:var(--surface-variant); border-radius:var(--radius-m);
|
||||
padding:14px 16px; border:var(--outline-w) solid var(--outline-variant);
|
||||
}
|
||||
.stat-label{ color:var(--on-surface-variant); font-size:12px; margin-bottom:6px; }
|
||||
.stat-value{ font-size:22px; font-weight:700; letter-spacing:.3px; }
|
||||
.stat-unit{ font-size:13px; font-weight:400; color:var(--on-surface-variant); margin-left:4px; }
|
||||
.stat-sub{ font-size:12px; color:var(--on-surface-variant); margin-top:6px; }
|
||||
|
||||
/* 进度条 */
|
||||
.bar{ height:8px; border-radius:99px; background:var(--surface-dim); overflow:hidden; margin-top:8px; }
|
||||
.bar > span{ display:block; height:100%; border-radius:99px; background:var(--primary); transition:width .4s ease; }
|
||||
.bar.warn > span{ background:var(--warning); }
|
||||
.bar.danger > span{ background:var(--error); }
|
||||
|
||||
/* 按钮 */
|
||||
.btn{
|
||||
border:none; padding:9px 16px; border-radius:var(--radius-m);
|
||||
background:var(--surface-variant); color:var(--on-surface);
|
||||
cursor:pointer; font:inherit; font-size:14px;
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
transition:filter var(--speed), background var(--speed), transform .05s;
|
||||
}
|
||||
.btn:hover{ filter:brightness(1.08); }
|
||||
.btn:active{ transform:scale(.98); }
|
||||
.btn:disabled{ opacity:.5; cursor:not-allowed; }
|
||||
.btn-primary{ background:var(--primary); color:var(--on-primary); }
|
||||
.btn-danger{ background:var(--error); color:var(--on-error); }
|
||||
.btn-ghost{ background:transparent; }
|
||||
.icon-txt{ font-size:16px; }
|
||||
|
||||
/* 表格(文件/日志) */
|
||||
.table{ width:100%; border-collapse:collapse; }
|
||||
.table th, .table td{ text-align:left; padding:10px 12px; border-bottom:1px solid var(--outline-variant); }
|
||||
.table th{ color:var(--on-surface-variant); font-weight:500; font-size:12px; }
|
||||
.table tr:hover td{ background:var(--hover); }
|
||||
.row-actions{ display:flex; gap:6px; justify-content:flex-end; }
|
||||
.muted{ color:var(--on-surface-variant); font-size:12px; }
|
||||
.mono{ font-family:var(--mono); font-size:12px; word-break:break-all; }
|
||||
|
||||
/* 面包屑 */
|
||||
.crumbs{ display:flex; flex-wrap:wrap; align-items:center; gap:4px; margin-bottom:12px; }
|
||||
.crumbs a{ color:var(--primary); cursor:pointer; text-decoration:none; }
|
||||
.crumbs a:hover{ text-decoration:underline; }
|
||||
|
||||
/* 表单行 */
|
||||
.field{ margin-bottom:16px; }
|
||||
.field-label{ display:block; margin-bottom:6px; color:var(--on-surface-variant); font-size:13px; }
|
||||
.field-row{ display:flex; align-items:center; gap:12px; }
|
||||
.field-row.wrap{ flex-wrap:wrap; }
|
||||
input[type=text], input[type=number], select, textarea{
|
||||
width:100%; padding:9px 12px; border-radius:var(--radius-m);
|
||||
border:var(--outline-w) solid var(--outline); background:var(--surface-variant);
|
||||
color:var(--on-surface); font:inherit; font-size:14px; border:0;
|
||||
}
|
||||
textarea{ resize:vertical; font-family:var(--mono); font-size:12px; }
|
||||
input[type=range]{ width:100%; accent-color:var(--primary); }
|
||||
.field-row input[type=range]{ flex:1; min-width:0; }
|
||||
|
||||
/* 开关 */
|
||||
.switch{ position:relative; width:46px; height:26px; flex:0 0 46px; }
|
||||
.switch input{ position:absolute; opacity:0; inset:0; margin:0; cursor:pointer; }
|
||||
.switch .track{ position:absolute; inset:0; border-radius:99px; background:var(--surface-dim); transition:background var(--speed); border:var(--outline-w) solid var(--outline-variant); }
|
||||
.switch .thumb{ position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%; background:var(--surface); box-shadow:var(--shadow); transition:left var(--speed); }
|
||||
.switch input:checked + .track{ background:var(--primary); }
|
||||
.switch input:checked + .track .thumb{ left:23px; background:var(--on-primary); }
|
||||
|
||||
/* 应用卡片 */
|
||||
.app-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:14px; }
|
||||
.app-card{
|
||||
background:var(--surface-variant); border-radius:var(--radius-l);
|
||||
border:var(--outline-w) solid var(--outline-variant); padding:16px;
|
||||
display:flex; flex-direction:column; gap:8px;
|
||||
}
|
||||
.app-card .app-head{ display:flex; align-items:center; gap:12px; }
|
||||
.app-card .app-ico{ width:40px; height:40px; border-radius:var(--radius-m); background:var(--primary); color:var(--on-primary); display:flex; align-items:center; justify-content:center; font-weight:700; font-size:16px; flex:0 0 40px; }
|
||||
.app-card .app-name{ font-weight:600; }
|
||||
.app-card .app-pkg{ font-size:12px; color:var(--on-surface-variant); font-family:var(--mono); }
|
||||
.app-card .app-meta{ font-size:12px; color:var(--on-surface-variant); }
|
||||
.app-card .app-actions{ display:flex; gap:6px; margin-top:auto; flex-wrap:wrap; }
|
||||
|
||||
/* 远程按键区 */
|
||||
.key-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:12px; }
|
||||
.key-btn{
|
||||
padding:14px; border:none; border-radius:var(--radius-m);
|
||||
background:var(--surface-variant); color:var(--on-surface);
|
||||
border:var(--outline-w) solid var(--outline-variant);
|
||||
cursor:pointer; font:inherit; text-align:center; transition:background var(--speed);
|
||||
}
|
||||
.key-btn:hover{ background:var(--hover); }
|
||||
.key-btn .k-ico{ font-size:20px; display:block; margin-bottom:4px; }
|
||||
|
||||
/* 危险区 */
|
||||
.danger-box{ border:1px solid var(--error); border-radius:var(--radius-l); padding:16px 18px; }
|
||||
.danger-box .card-title{ color:var(--error); }
|
||||
.warn-note{ color:var(--warning); font-size:12px; margin-top:8px; }
|
||||
|
||||
/* 日志查看 */
|
||||
.log-view{ background:var(--surface-dim); border-radius:var(--radius-m); padding:12px; max-height:60vh; overflow:auto; font-family:var(--mono); font-size:12px; white-space:pre-wrap; color:var(--on-surface); }
|
||||
|
||||
/* 通知列表 */
|
||||
.notif{ display:flex; gap:12px; padding:12px; border-radius:var(--radius-m); background:var(--surface-variant); margin-bottom:10px; align-items:flex-start; border:var(--outline-w) solid var(--outline-variant); }
|
||||
.notif .n-ico{ width:36px; height:36px; border-radius:var(--radius-m); background:var(--primary); color:var(--on-primary); display:flex; align-items:center; justify-content:center; flex:0 0 36px; font-weight:700; }
|
||||
.notif .n-body{ flex:1; min-width:0; }
|
||||
.notif .n-title{ font-weight:600; }
|
||||
.notif .n-text{ color:var(--on-surface-variant); font-size:13px; margin-top:2px; }
|
||||
.notif .n-meta{ display:flex; gap:8px; margin-top:6px; color:var(--on-surface-variant); font-size:11px; }
|
||||
.urgency-hi{ color:var(--error); }
|
||||
|
||||
/* 横幅 / toast / modal */
|
||||
.offline-banner{
|
||||
position:sticky; top:0; z-index:30; padding:10px 16px;
|
||||
background:var(--error); color:var(--on-error); font-size:13px;
|
||||
}
|
||||
.offline-banner code{ background:rgba(0,0,0,.2); padding:1px 5px; border-radius:4px; }
|
||||
|
||||
.toast{
|
||||
position:fixed; left:50%; bottom:28px; transform:translateX(-50%);
|
||||
z-index:60; background:var(--surface); color:var(--on-surface);
|
||||
padding:11px 18px; border-radius:var(--radius-m); box-shadow:var(--shadow);
|
||||
border:var(--outline-w) solid var(--outline-variant); font-size:13px;
|
||||
max-width:80vw;
|
||||
}
|
||||
|
||||
.modal-scrim{
|
||||
position:fixed; inset:0; z-index:50; background:var(--scrim);
|
||||
display:flex; align-items:center; justify-content:center; padding:20px;
|
||||
}
|
||||
.modal-scrim[hidden]{ display:none; } /* 覆盖上方 display:flex,否则 hidden 失效 */
|
||||
.modal{ background:var(--surface); border-radius:var(--radius-l); width:min(420px,100%); padding:22px; box-shadow:var(--shadow); border:var(--outline-w) solid var(--outline-variant); }
|
||||
.modal-title{ font-size:16px; margin:0 0 10px; }
|
||||
.modal-body{ color:var(--on-surface-variant); margin:0 0 18px; word-break:break-all; }
|
||||
.modal-actions{ display:flex; justify-content:flex-end; gap:10px; }
|
||||
|
||||
/* 空态 / 加载 */
|
||||
.empty{ text-align:center; color:var(--on-surface-variant); padding:48px 0; }
|
||||
.skel{ color:var(--on-surface-variant); }
|
||||
.spinner{ display:inline-block; width:18px; height:18px; border:2px solid var(--outline-variant); border-top-color:var(--primary); border-radius:50%; animation:spin .7s linear infinite; vertical-align:middle; }
|
||||
@keyframes spin{ to{ transform:rotate(360deg); } }
|
||||
|
||||
/* 响应式:窄屏收起侧栏 */
|
||||
@media (max-width:820px){
|
||||
:root{ --nav-w:216px; }
|
||||
.sidebar{ position:fixed; left:0; top:0; bottom:0; z-index:40; margin-left:calc(-1 * var(--nav-w)); }
|
||||
.sidebar.open{ margin-left:0; box-shadow:var(--shadow); }
|
||||
.menu-btn{ display:flex; }
|
||||
.brand-name{ display:none; }
|
||||
.conn-text{ display:none; }
|
||||
}
|
||||
@media (max-width:520px){
|
||||
.content{ padding:14px; }
|
||||
.topbar{ padding:0 10px; gap:8px; }
|
||||
.seg-btn{ padding:6px 9px; }
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
/* MeowPanel — 前端逻辑(纯静态,零第三方依赖)
|
||||
*
|
||||
* 对接协议:devdocs/plan/MEOWPANEL_PROTOCOL.md(草案,后端未实现)。
|
||||
* 所有 fetch 失败都优雅降级为「后端未连接」,不假设后端就绪。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ---------------- 小工具 ---------------- */
|
||||
const $ = (sel, root) => (root || document).querySelector(sel);
|
||||
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[m]));
|
||||
}
|
||||
function fmtBytes(n) {
|
||||
if (n == null || isNaN(n)) return '—';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let v = Number(n), i = 0;
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
||||
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
|
||||
}
|
||||
function fmtUptime(sec) {
|
||||
if (sec == null || isNaN(sec)) return '—';
|
||||
sec = Math.floor(sec);
|
||||
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
|
||||
return (d > 0 ? d + ' 天 ' : '') + h + ' 小时 ' + m + ' 分';
|
||||
}
|
||||
function fmtTime(ts) { return ts ? new Date(ts * 1000).toLocaleString() : '—'; }
|
||||
function debounce(fn, ms) { let t = null; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), ms); }; }
|
||||
function pct(p) { return Math.max(0, Math.min(100, p)); }
|
||||
|
||||
/* ---------------- 全局状态 ---------------- */
|
||||
const state = {
|
||||
style: 'md3', theme: 'dark',
|
||||
conn: 'unknown',
|
||||
filesRel: '/', filesAbs: null,
|
||||
perfTimer: null, // 性能页轮询
|
||||
route: 'perf',
|
||||
downloadNonce: 0
|
||||
};
|
||||
|
||||
/* ---------------- 后端连接状态 ---------------- */
|
||||
function setConn(ok) {
|
||||
state.conn = ok ? 'ok' : 'bad';
|
||||
const dot = $('#conn-dot');
|
||||
dot.dataset.state = state.conn;
|
||||
$('#conn-text').textContent = ok ? '在线' : '离线';
|
||||
$('#offline-banner').hidden = !!ok;
|
||||
}
|
||||
|
||||
/* ---------------- API 客户端 ---------------- */
|
||||
// 返回 {code,message,status} 形异常;网络/非 JSON → 视为断连
|
||||
async function api(method, path, body) {
|
||||
let res;
|
||||
const init = { method: method || 'GET' };
|
||||
if (body instanceof FormData) {
|
||||
init.body = body;
|
||||
} else if (body !== undefined && body !== null) {
|
||||
init.headers = { 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
try {
|
||||
res = await fetch(path, init);
|
||||
} catch (e) {
|
||||
setConn(false);
|
||||
throw { code: 'NETWORK', message: '后端未连接', status: 0 };
|
||||
}
|
||||
let json = null;
|
||||
try { json = await res.json(); } catch (e) { json = null; }
|
||||
if (!json || typeof json.ok !== 'boolean') {
|
||||
// 静态服务器返回 HTML / 404 等
|
||||
setConn(res.ok ? true : false);
|
||||
throw { code: 'NOT_JSON', message: '后端返回了非 JSON 响应(HTTP ' + res.status + ')', status: res.status };
|
||||
}
|
||||
if (!json.ok) {
|
||||
setConn(true);
|
||||
const code = (json.error && json.error.code) || 'ERROR';
|
||||
throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status };
|
||||
}
|
||||
setConn(true);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// 下载(返回 blob URL)
|
||||
async function apiDownload(path) {
|
||||
let res;
|
||||
try { res = await fetch(path); }
|
||||
catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; }
|
||||
if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; }
|
||||
setConn(true);
|
||||
return URL.createObjectURL(await res.blob());
|
||||
}
|
||||
|
||||
/* ---------------- toast / 确认弹窗 ---------------- */
|
||||
let toastTimer = null;
|
||||
function toast(msg, ms) {
|
||||
const t = $('#toast');
|
||||
t.textContent = msg; t.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { t.hidden = true; }, ms || 2600);
|
||||
}
|
||||
function confirmDlg(title, body, danger) {
|
||||
return new Promise(res => {
|
||||
$('#modal-title').textContent = title || '确认';
|
||||
$('#modal-body').textContent = body || '';
|
||||
const yes = $('#modal-yes'), no = $('#modal-no');
|
||||
yes.className = 'btn btn-primary' + (danger ? ' btn-danger' : '');
|
||||
const scrim = $('#modal'); scrim.hidden = false;
|
||||
const done = v => { scrim.hidden = true; yes.onclick = no.onclick = scrim.onclick = null; res(v); };
|
||||
yes.onclick = () => done(true);
|
||||
no.onclick = () => done(false);
|
||||
scrim.onclick = e => { if (e.target === scrim) done(false); };
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 主题(MD3/MIUIx × 深/浅) ---------------- */
|
||||
function applyTheme(style, theme) {
|
||||
state.style = style; state.theme = theme;
|
||||
const root = document.documentElement;
|
||||
root.dataset.style = style; root.dataset.theme = theme;
|
||||
$$('#style-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.style === style));
|
||||
$$('#theme-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.theme === theme));
|
||||
try { localStorage.setItem('meowpanel.style', style); localStorage.setItem('meowpanel.theme', theme); } catch (e) {}
|
||||
}
|
||||
async function sendTheme(patch) {
|
||||
try { await api('PUT', '/api/theme', patch); }
|
||||
catch (e) { toast('主题已本地切换(后端未同步:' + e.message + ')'); }
|
||||
}
|
||||
async function initTheme() {
|
||||
let style = 'md3', theme = 'dark';
|
||||
try { style = localStorage.getItem('meowpanel.style') || 'md3'; theme = localStorage.getItem('meowpanel.theme') || 'dark'; } catch (e) {}
|
||||
applyTheme(style, theme);
|
||||
try {
|
||||
const t = await api('GET', '/api/theme');
|
||||
applyTheme(t.style || style, t.theme || theme);
|
||||
} catch (e) { /* 后端离线,保持本地 */ }
|
||||
}
|
||||
// 顶栏「风格 / 明暗」切换器:本地立即切换 + 同步后端
|
||||
function bindThemeUi() {
|
||||
$('#style-seg').addEventListener('click', function (e) {
|
||||
const b = e.target.closest('.seg-btn'); if (!b) return;
|
||||
applyTheme(b.dataset.style, state.theme);
|
||||
sendTheme({ style: b.dataset.style });
|
||||
});
|
||||
$('#theme-seg').addEventListener('click', function (e) {
|
||||
const b = e.target.closest('.seg-btn'); if (!b) return;
|
||||
applyTheme(state.style, b.dataset.theme);
|
||||
sendTheme({ theme: b.dataset.theme });
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 页面空态 / 错误提示 ---------------- */
|
||||
function emptyState(msg, code) {
|
||||
const t = code === 'NO_CARD' ? '未识别到 TF 卡' : (code === 'NOT_IMPLEMENTED' ? '该功能后端尚未实现' : msg);
|
||||
return '<div class="empty">' + esc(t) + '</div>';
|
||||
}
|
||||
function errText(e) {
|
||||
if (e && e.code === 'NO_CARD') return '未识别到 TF 卡';
|
||||
if (e && e.code === 'NOT_IMPLEMENTED') return '该功能后端尚未实现';
|
||||
return (e && e.message) ? e.message : '未知错误';
|
||||
}
|
||||
|
||||
/* ---------------- 1. 性能页(2s 轮询 /api/perf) ---------------- */
|
||||
function barHtml(p) {
|
||||
p = pct(p);
|
||||
const c = p >= 90 ? 'danger' : (p >= 70 ? 'warn' : '');
|
||||
return '<div class="bar ' + c + '"><span style="width:' + p + '%"></span></div>';
|
||||
}
|
||||
function stat(label, valueHtml, sub, barPct) {
|
||||
return '<div class="stat"><div class="stat-label">' + esc(label) + '</div>' + valueHtml +
|
||||
(sub ? '<div class="stat-sub">' + sub + '</div>' : '') +
|
||||
(barPct != null ? barHtml(barPct) : '') + '</div>';
|
||||
}
|
||||
function renderPerfData(page, d) {
|
||||
const cpu = d.cpu || {};
|
||||
const mem = d.memory || {}, st = d.storage || {}, tf = d.tfcard || {}, tmp = d.temp;
|
||||
let h = '<div class="card"><h3 class="card-title">实时性能</h3>';
|
||||
h += '<div class="muted">运行时间:' + fmtUptime(d.uptime_sec) + '</div><div class="stat-grid">';
|
||||
h += stat('CPU', '<span class="stat-value">' + (cpu.usage == null ? '—' : +cpu.usage.toFixed(1)) + '<span class="stat-unit">%</span></span>',
|
||||
cpu.cores != null ? cpu.cores + ' 核 · 负载 ' + (cpu.load ? cpu.load.map(x => (+x).toFixed(2)).join(' / ') : '—') : '',
|
||||
cpu.usage || 0);
|
||||
const memP = mem.total ? (mem.used / mem.total) * 100 : 0;
|
||||
h += stat('内存', '<span class="stat-value">' + fmtBytes((mem.used || 0) * 1024) + '</span><span class="stat-unit">/ ' + fmtBytes((mem.total || 0) * 1024) + '</span>',
|
||||
'可用 ' + fmtBytes((mem.available || (mem.free || 0)) * 1024), memP);
|
||||
const stP = st.total ? (st.used / st.total) * 100 : 0;
|
||||
h += stat('存储 (rootfs)', '<span class="stat-value">' + fmtBytes((st.used || 0) * 1024) + '</span><span class="stat-unit">/ ' + fmtBytes((st.total || 0) * 1024) + '</span>', st.path || '', stP);
|
||||
if (tf) {
|
||||
if (tf.present) {
|
||||
const tfP = tf.total ? (tf.used / tf.total) * 100 : 0;
|
||||
h += stat('TF 卡', '<span class="stat-value">' + fmtBytes((tf.used || 0) * 1024) + '</span><span class="stat-unit">/ ' + fmtBytes((tf.total || 0) * 1024) + '</span>',
|
||||
fmtBytes((tf.avail || 0) * 1024) + ' 可用 · ' + (tf.filesystem || ''), tfP);
|
||||
} else {
|
||||
h += stat('TF 卡', '<span class="stat-value">未插卡</span>', '');
|
||||
}
|
||||
}
|
||||
const tmpCpu = tmp && tmp.cpu != null ? +tmp.cpu.toFixed(1) : null;
|
||||
h += stat('温度', '<span class="stat-value">' + (tmpCpu == null ? '—' : tmpCpu) + '<span class="stat-unit">℃</span></span>',
|
||||
tmp && tmp.board != null ? '板载 ' + (+tmp.board.toFixed(1)) + ' ℃' : '');
|
||||
h += '</div></div>';
|
||||
page.innerHTML = h;
|
||||
}
|
||||
async function fetchPerfOnce() {
|
||||
const page = $('#perf-page');
|
||||
if (!page || state.route !== 'perf') return;
|
||||
let d;
|
||||
try { d = await api('GET', '/api/perf'); }
|
||||
catch (e) { if (page && state.route === 'perf') page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||
if (state.route === 'perf') renderPerfData(page, d);
|
||||
}
|
||||
function stopPerf() { if (state.perfTimer) { clearInterval(state.perfTimer); state.perfTimer = null; } }
|
||||
function renderPerf() {
|
||||
$('#content').innerHTML = '<div class="page" id="perf-page"><p class="placeholder">加载中…</p></div>';
|
||||
if (state.perfTimer) stopPerf();
|
||||
fetchPerfOnce();
|
||||
state.perfTimer = setInterval(fetchPerfOnce, 2000);
|
||||
}
|
||||
|
||||
/* ---------------- 2. 亮度 / 音量页 ---------------- */
|
||||
let volTimer = null;
|
||||
function slur(step) { // step: {key,label,min,max,val}
|
||||
return '<div class="card"><h3 class="card-title">' + esc(step.label) + '</h3>' +
|
||||
'<div class="field-row"><span class="muted" style="width:44px">' + step.val + '</span>' +
|
||||
'<input type="range" min="' + step.min + '" max="' + step.max + '" value="' + step.val + '" data-slider data-key="' + esc(step.key) + '">' +
|
||||
'<span class="muted" style="width:44px;text-align:right">' + step.max + '</span></div></div>';
|
||||
}
|
||||
async function renderBright() {
|
||||
$('#content').innerHTML = '<div class="page" id="bright-page"><p class="placeholder">加载设置…</p></div>';
|
||||
let bright, vol;
|
||||
try {
|
||||
bright = await api('GET', '/api/settings/display.brightness');
|
||||
} catch (e) { $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||
try { vol = await api('GET', '/api/settings/audio.volume'); } catch (e) { vol = null; }
|
||||
if (state.route !== 'bright') return;
|
||||
let h = '<div class="page" id="bright-page">';
|
||||
h += slur({ key: 'display.brightness', label: '亮度', min: bright.min || 1, max: bright.max || 100, val: bright.value });
|
||||
if (vol) h += slur({ key: 'audio.volume', label: '音量', min: vol.min || 0, max: vol.max || 100, val: vol.value });
|
||||
else h += '<p class="muted">音量设置项不可用(后端未实现 audio.volume)</p>';
|
||||
h += '<p class="muted">滑条改动自动保存到系统(走 meow_settings_*),与实体设置页同步。</p></div>';
|
||||
$('#content').innerHTML = h;
|
||||
}
|
||||
const saveSetting = debounce(async function (key, value, type) {
|
||||
try {
|
||||
await api('PUT', '/api/settings/' + key, { value: value });
|
||||
toast('已保存 ' + key + ' = ' + value);
|
||||
} catch (e) { toast('保存失败:' + errText(e)); }
|
||||
}, 400);
|
||||
function bindSlider() {
|
||||
$('#content').addEventListener('input', function (e) {
|
||||
const r = e.target.closest('[data-slider]');
|
||||
if (!r) return;
|
||||
const row = r.closest('.field-row');
|
||||
if (row) row.querySelector('.muted').textContent = r.value;
|
||||
saveSetting(r.dataset.key, +r.value, 'int');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 3. 文件管理页 ---------------- */
|
||||
// 返回带索引的 rel 显示面包屑
|
||||
function joinRel(a, b) {
|
||||
if (!a || a === '/') return '/' + b;
|
||||
return (a.endsWith('/') ? a.slice(0, -1) : a) + '/' + b;
|
||||
}
|
||||
function crumbHtml(items, cur) { // items: [{name,rel}]
|
||||
let h = '<div class="crumbs" id="crumbs">';
|
||||
h += '<a data-rel="/" id="crumb-root">根目录</a>';
|
||||
items.forEach(it => { h += '<span>/</span><a data-rel="' + esc(it.rel) + '">' + esc(it.name) + '</a>'; });
|
||||
return h + '</div>';
|
||||
}
|
||||
async function navFiles(rel) {
|
||||
const page = $('#files-page');
|
||||
if (!page) return;
|
||||
let relPath = rel || '/';
|
||||
page.innerHTML = '<p class="placeholder">加载中…</p>';
|
||||
let d;
|
||||
try { d = await api('GET', '/api/files?path=' + encodeURIComponent(relPath)); }
|
||||
catch (e) { page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||
state.filesRel = d.rel || relPath; state.filesAbs = d.path;
|
||||
let h = crumbHtml(segments(d.rel), d.rel);
|
||||
// 白名单快捷入口(根目录下)
|
||||
if (d.rel === '/' && d.allowed) {
|
||||
h += '<div class="field-row wrap" style="margin-bottom:12px">' +
|
||||
d.allowed.map(a => '<button class="btn" data-nav="' + esc('/' + a) + '">' + esc(a) + '</button>').join(' ') + '</div>';
|
||||
}
|
||||
h += '<div class="field-row wrap" style="margin-bottom:12px">' +
|
||||
'<button class="btn" id="file-newdir">新建目录</button>' +
|
||||
'<button class="btn" id="file-upload">上传文件</button>' +
|
||||
'<button class="btn" id="file-refresh">刷新</button>' +
|
||||
'<input type="file" id="file-input" multiple hidden></div>';
|
||||
h += '<div class="card"><table class="table"><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
|
||||
if (!d.items.length) h += '<tr><td colspan="4" class="empty">空目录</td></tr>';
|
||||
d.items.forEach(function (it) {
|
||||
const isDir = it.type === 'dir';
|
||||
const icon = isDir ? '📁' : (it.type === 'file' ? '📄' : '❔');
|
||||
h += '<tr><td>' + (isDir ? '<a class="dirlink" data-dir="' + esc(it.name) + '">' : '') + icon + ' ' + esc(it.name) + (isDir ? '</a>' : '') +
|
||||
'</td><td class="muted">' + (isDir ? '—' : fmtBytes(it.size)) + '</td><td class="muted">' + fmtTime(it.mtime) + '</td>' +
|
||||
'<td><div class="row-actions">' +
|
||||
(isDir ? '<button class="btn btn-ghost" data-enter="' + esc(it.name) + '">打开</button>' : '<a class="btn btn-ghost" data-dl="' + esc(it.name) + '" href="#">下载</a>') +
|
||||
'<button class="btn btn-ghost" data-ren="' + esc(it.name) + '">重命名</button>' +
|
||||
'<button class="btn btn-ghost btn-danger" data-del="' + esc(it.name) + '">删除</button>' +
|
||||
'</div></td></tr>';
|
||||
});
|
||||
h += '</tbody></table></div>';
|
||||
page.innerHTML = h;
|
||||
}
|
||||
function segments(rel) {
|
||||
const parts = (rel || '').split('/').filter(Boolean); const out = []; let cur = '';
|
||||
parts.forEach(p => { cur += '/' + p; out.push({ name: p, rel: cur }); });
|
||||
return out;
|
||||
}
|
||||
function renderFiles() { $('#content').innerHTML = '<div class="page" id="files-page"></div>'; navFiles(state.filesRel || '/'); }
|
||||
function absFor(name) { return state.filesAbs ? (state.filesAbs.endsWith('/') ? state.filesAbs + name : state.filesAbs + '/' + name) : null; }
|
||||
function bindFiles() {
|
||||
const c = $('#content');
|
||||
c.addEventListener('click', async function (e) {
|
||||
const t = e.target;
|
||||
const enter = t.closest('[data-enter]'); const dl = t.closest('[data-dl]');
|
||||
const ren = t.closest('[data-ren]'); const del = t.closest('[data-del]');
|
||||
const dirlink = t.closest('[data-dir]'); const nav = t.closest('[data-nav]');
|
||||
const crumb = t.closest('[data-rel]');
|
||||
const btn = t.closest('button[id]');
|
||||
if (crumb) { navFiles(crumb.dataset.rel); return; }
|
||||
if (nav) { navFiles(nav.dataset.nav); return; }
|
||||
if (dirlink || enter) { const n = (dirlink || enter).dataset.directory || (dirlink || enter).dataset.dir || (dirlink || enter).dataset.enter; navFiles(joinRel(state.filesRel, n)); return; }
|
||||
if (dl) {
|
||||
const abs = absFor(dl.dataset.dl); if (!abs) return toast('无当前目录');
|
||||
doDownload('/api/files/download?path=' + encodeURIComponent(abs), dl.dataset.dl);
|
||||
return;
|
||||
}
|
||||
if (ren) { renameFile(ren.dataset.ren); return; }
|
||||
if (del) { removeFile(del.dataset.del); return; }
|
||||
if (btn && btn.id === 'file-newdir') { mkdirFile(); return; }
|
||||
if (btn && btn.id === 'file-upload') { const fi = $('#file-input'); if (fi) { fi.value = ''; fi.click(); } return; }
|
||||
if (btn && btn.id === 'file-refresh') { navFiles(state.filesRel); return; }
|
||||
});
|
||||
c.addEventListener('change', function (e) {
|
||||
if (e.target.id === 'file-input') uploadFiles(e.target.files);
|
||||
});
|
||||
}
|
||||
function doDownload(url, name) {
|
||||
apiDownload(url).then(blobUrl => {
|
||||
const a = document.createElement('a'); a.href = blobUrl; a.download = name || 'file';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
|
||||
}).catch(e => toast('下载失败:' + errText(e)));
|
||||
}
|
||||
async function removeFile(name) {
|
||||
const abs = absFor(name); if (!abs) return;
|
||||
const ok = await confirmDlg('删除 ' + name, '此操作不可撤销,确认删除?\n' + abs, true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api('DELETE', '/api/files?path=' + encodeURIComponent(abs));
|
||||
toast('已删除 ' + name); navFiles(state.filesRel);
|
||||
} catch (e) { toast('删除失败:' + errText(e)); }
|
||||
}
|
||||
async function renameFile(name) {
|
||||
const abs = absFor(name); if (!abs) return;
|
||||
const nn = prompt('将「' + name + '」重命名为:', name);
|
||||
if (!nn || nn === name) return;
|
||||
try {
|
||||
await api('POST', '/api/files/rename', { path: abs, new_name: nn });
|
||||
toast('已重命名'); navFiles(state.filesRel);
|
||||
} catch (e) { toast('重命名失败:' + errText(e)); }
|
||||
}
|
||||
async function mkdirFile() {
|
||||
const abs = state.filesAbs; if (!abs) return;
|
||||
const nn = prompt('新建目录名称(将创建于 ' + state.filesRel + '):', 'newdir');
|
||||
if (!nn) return;
|
||||
try {
|
||||
await api('POST', '/api/files/mkdir', { path: joinRel(abs, nn) });
|
||||
toast('已创建 ' + nn); navFiles(state.filesRel);
|
||||
} catch (e) { toast('创建失败:' + errText(e)); }
|
||||
}
|
||||
async function uploadFiles(files) {
|
||||
if (!files || !files.length) return;
|
||||
const abs = state.filesAbs; if (!abs) return toast('无当前目录');
|
||||
const fd = new FormData(); fd.append('path', abs);
|
||||
Array.prototype.forEach.call(files, f => fd.append('file', f));
|
||||
const n = files.length;
|
||||
toast('正在上传 ' + n + ' 个文件…');
|
||||
try {
|
||||
await api('POST', '/api/files/upload', fd);
|
||||
toast('上传完成'); navFiles(state.filesRel);
|
||||
} catch (e) { toast('上传失败:' + errText(e)); }
|
||||
}
|
||||
|
||||
/* ---------------- 4. 应用管理页 ---------------- */
|
||||
function renderApps() {
|
||||
$('#content').innerHTML = '<div class="page" id="apps-page"><p class="placeholder">加载应用列表…</p></div>';
|
||||
(async function () {
|
||||
let d;
|
||||
try { d = await api('GET', '/api/apps'); }
|
||||
catch (e) { if (state.route === 'apps') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||
if (state.route !== 'apps') return;
|
||||
let h = '<div class="page" id="apps-page">';
|
||||
h += '<div class="field-row" style="margin-bottom:14px">' +
|
||||
'<button class="btn btn-primary" id="app-install">导入 .mpk 安装</button>' +
|
||||
'<button class="btn" id="app-refresh">刷新</button>' +
|
||||
'<input type="file" id="app-file" accept=".mpk" hidden></div>';
|
||||
h += '<div class="app-grid">';
|
||||
if (!d.apps.length) h += '<div class="empty" style="grid-column:1/-1">尚未安装任何 MeowAPP</div>';
|
||||
d.apps.forEach(a => {
|
||||
const ino = esc((a.name || a.package || '?').charAt(0).toUpperCase());
|
||||
h += '<div class="app-card" data-pkg="' + esc(a.package || '') + '">' +
|
||||
'<div class="app-head"><div class="app-ico">' + ino + '</div>' +
|
||||
'<div><div class="app-name">' + esc(a.name || a.package || '未命名') + '</div>' +
|
||||
'<div class="app-pkg">' + esc(a.package || '') + '</div></div></div>' +
|
||||
'<div class="app-meta">版本 ' + esc(a.version || '—') + (a.invalid ? ' · <span style="color:var(--error)">无效</span>' : '') +
|
||||
(a.size ? ' · ' + fmtBytes(a.size) : '') + '</div>' +
|
||||
'<div class="app-actions">' +
|
||||
'<button class="btn btn-ghost" data-ainfo="' + esc(a.package) + '">详情</button>' +
|
||||
'<a class="btn btn-ghost" data-aexport="' + esc(a.package) + '" href="#">导出 .mpk</a>' +
|
||||
'<button class="btn btn-danger" data-auninst="' + esc(a.package) + '">卸载</button>' +
|
||||
'</div></div>';
|
||||
});
|
||||
h += '</div>';
|
||||
// 本地源 .mpk 安装(后端支持 path 形式)
|
||||
h += '<div class="card"><h3 class="card-title">从卡上路径安装</h3>' +
|
||||
'<div class="field-row"><input type="text" id="app-install-path" placeholder="如 /MeowOS/tmp/os.meow.hello.mpk">' +
|
||||
'<button class="btn" id="app-install-path-go">安装</button></div></div>';
|
||||
$('#content').innerHTML = h;
|
||||
})();
|
||||
}
|
||||
function bindApps() {
|
||||
const c = $('#content');
|
||||
c.addEventListener('click', async function (e) {
|
||||
const t = e.target;
|
||||
const install = t.closest('#app-install'); const refresh = t.closest('#app-refresh');
|
||||
const info = t.closest('[data-ainfo]'); const exp = t.closest('[data-aexport]');
|
||||
const uninst = t.closest('[data-auninst]'); const goPath = t.closest('#app-install-path-go');
|
||||
if (install) { const fi = $('#app-file'); if (fi) { fi.value = ''; fi.click(); } return; }
|
||||
if (refresh) { renderApps(); return; }
|
||||
if (info) { appInfo(info.dataset.ainfo); return; }
|
||||
if (exp) { e.preventDefault(); doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk'); return; }
|
||||
if (uninst) { appUninstall(uninst.dataset.auninst); return; }
|
||||
if (goPath) { installFromPath($('#app-install-path').value); return; }
|
||||
});
|
||||
c.addEventListener('change', function (e) {
|
||||
if (e.target.id === 'app-file') installFromFile(e.target.files);
|
||||
});
|
||||
}
|
||||
async function installFromFile(files) {
|
||||
if (!files || !files.length) return;
|
||||
const fd = new FormData(); fd.append('file', files[0]);
|
||||
toast('正在安装 ' + files[0].name + '…');
|
||||
try {
|
||||
const d = await api('POST', '/api/apps/install', fd);
|
||||
toast('已安装 ' + (d.package || '应用') + (d.version ? ' v' + d.version : ''));
|
||||
renderApps();
|
||||
} catch (e) { toast('安装失败:' + errText(e)); }
|
||||
}
|
||||
async function installFromPath(p) {
|
||||
if (!p) return toast('请输入 .mpk 路径');
|
||||
toast('正在安装 ' + p + '…');
|
||||
try {
|
||||
const d = await api('POST', '/api/apps/install', { path: p });
|
||||
toast('已安装 ' + (d.package || '应用')); renderApps();
|
||||
} catch (e) { toast('安装失败:' + errText(e)); }
|
||||
}
|
||||
async function appUninstall(pkg) {
|
||||
const ok = await confirmDlg('卸载 ' + pkg, '将移除应用与数据预留(数据默认保留)。确认卸载?', true);
|
||||
if (!ok) return;
|
||||
try { await api('DELETE', '/api/apps/' + pkg); toast('已卸载 ' + pkg); renderApps(); }
|
||||
catch (e) { toast('卸载失败:' + errText(e)); }
|
||||
}
|
||||
async function appInfo(pkg) {
|
||||
try {
|
||||
const d = await api('GET', '/api/apps/' + pkg);
|
||||
let txt = '包名: ' + d.package + '\n名称: ' + d.name + '\n版本: ' + d.version + '\nexec: ' + d.exec +
|
||||
'\nsize: ' + fmtBytes(d.size) + '\n路径: ' + d.path;
|
||||
if (d.description) txt += '\n描述: ' + d.description;
|
||||
alert(txt);
|
||||
} catch (e) { toast('获取详情失败:' + errText(e)); }
|
||||
}
|
||||
|
||||
/* ---------------- 5. 设置镜像页 ---------------- */
|
||||
function settingControl(it) {
|
||||
const key = it.key;
|
||||
if (it.type === 'bool') {
|
||||
return '<div class="field-row"><div style="flex:1" class="field-label"><div style="margin:0">' + esc(key) + '</div></div>' +
|
||||
'<label class="switch"><input type="checkbox" data-skey="' + esc(key) + '" data-stype="bool" ' + (it.value ? 'checked' : '') + '>' +
|
||||
'<span class="track"><span class="thumb"></span></span></label></div>';
|
||||
}
|
||||
if (it.type === 'int') {
|
||||
const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max);
|
||||
return '<div class="field"><label class="field-label">' + esc(key) + '(' + mn + '–' + mx + ')</label>' +
|
||||
'<div class="field-row"><span class="muted" style="width:44px">' + esc(it.value == null ? '' : it.value) + '</span>' +
|
||||
'<input type="range" min="' + mn + '" max="' + mx + '" value="' + esc(it.value == null ? mn : it.value) + '" data-skey="' + esc(key) + '" data-stype="int"></div></div>';
|
||||
}
|
||||
if (it.type === 'enum') {
|
||||
const opts = (it.options || []).map(o => '<option value="' + esc(o.name) + '" ' + (o.name === it.value ? 'selected' : '') + '>' + esc(o.label || o.name) + '</option>').join('');
|
||||
return '<div class="field"><label class="field-label">' + esc(key) + '</label><select data-skey="' + esc(key) + '" data-stype="enum">' + opts + '</select></div>';
|
||||
}
|
||||
return '<div class="field"><label class="field-label">' + esc(key) + '</label><input type="text" value="' + esc(it.value == null ? '' : it.value) + '" data-skey="' + esc(key) + '" data-stype="str"></div>';
|
||||
}
|
||||
async function renderSettings() {
|
||||
$('#content').innerHTML = '<div class="page" id="settings-page"><p class="placeholder">加载设置…</p></div>';
|
||||
let d;
|
||||
try { d = await api('GET', '/api/settings'); }
|
||||
catch (e) { if (state.route === 'settings') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||
if (state.route !== 'settings') return;
|
||||
let h = '<div class="page" id="settings-page">';
|
||||
h += '<div class="field-row" style="margin-bottom:14px">' +
|
||||
'<button class="btn btn-danger" id="set-restore">恢复全部默认</button>' +
|
||||
'<button class="btn" id="set-refresh">刷新</button></div>';
|
||||
const items = d.items || [];
|
||||
// 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看
|
||||
const groups = [['开关/布尔', items.filter(x => x.type === 'bool')],
|
||||
['数值/滑条', items.filter(x => x.type === 'int')],
|
||||
['枚举/选择', items.filter(x => x.type === 'enum')],
|
||||
['字符串/路径', items.filter(x => x.type === 'str')]];
|
||||
groups.forEach(function (g) {
|
||||
if (!g[1].length) return;
|
||||
h += '<div class="card"><h3 class="card-title">' + g[0] + '</h3>' + g[1].map(settingControl).join('') + '</div>';
|
||||
});
|
||||
h += '</div>';
|
||||
$('#content').innerHTML = h;
|
||||
}
|
||||
function bindSettings() {
|
||||
const c = $('#content');
|
||||
c.addEventListener('input', function (e) {
|
||||
const r = e.target.closest('[data-stype="int"]'); if (!r) return;
|
||||
const row = r.closest('.field-row'); if (row) row.querySelector('.muted').textContent = r.value;
|
||||
updateSetting(r.dataset.skey, +r.value, 'int');
|
||||
});
|
||||
c.addEventListener('change', function (e) {
|
||||
const t = e.target;
|
||||
const sk = t.dataset.skey; if (!sk) return;
|
||||
if (t.dataset.stype === 'bool') updateSetting(sk, t.checked, 'bool');
|
||||
else if (t.dataset.stype === 'enum') updateSetting(sk, t.value, 'enum');
|
||||
else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str');
|
||||
});
|
||||
c.addEventListener('click', async function (e) {
|
||||
if (e.target.closest('#set-restore')) {
|
||||
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
|
||||
if (!ok) return;
|
||||
try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); }
|
||||
catch (err) { toast('恢复失败:' + errText(err)); }
|
||||
} else if (e.target.closest('#set-refresh')) { renderSettings(); }
|
||||
});
|
||||
}
|
||||
const updateSetting = debounce(async function (key, value, type) {
|
||||
try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); }
|
||||
catch (e) { toast('保存失败:' + errText(e)); }
|
||||
}, 350);
|
||||
|
||||
/* ---------------- 6. 远程控制页 ---------------- */
|
||||
function renderRemote() {
|
||||
const keys = [
|
||||
{ code: 115, label: '音量 +', ico: '🔊' },
|
||||
{ code: 114, label: '音量 −', ico: '🔉' },
|
||||
{ code: 116, label: '电源(单击)', ico: '⏻' },
|
||||
{ code: 164, label: '播放/暂停', ico: '⏯' },
|
||||
{ code: 163, label: '下一曲', ico: '⏭' },
|
||||
{ code: 165, label: '上一曲', ico: '⏮' },
|
||||
{ code: 158, label: '返回', ico: '↩' },
|
||||
{ code: 172, label: '主页', ico: '⌂' }
|
||||
];
|
||||
let h = '<div class="page" id="remote-page">';
|
||||
h += '<div class="card"><h3 class="card-title">模拟按键</h3>' +
|
||||
'<p class="muted">行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。</p>' +
|
||||
'<div class="key-grid">' + keys.map(k =>
|
||||
'<button class="key-btn" data-code="' + k.code + '"><span class="k-ico">' + k.ico + '</span>' + esc(k.label) + '</button>'
|
||||
).join('') + '</div></div>';
|
||||
h += '<div class="card"><h3 class="card-title">电源长按(电源菜单)</h3>' +
|
||||
'<div class="field-row" style="gap:10px"><button class="btn btn-primary" data-powerlong="1">长按电源 3s(电源菜单)</button>' +
|
||||
'<span class="muted">发送 code=116 · press=long · ms=3000</span></div></div>';
|
||||
h += '</div>';
|
||||
$('#content').innerHTML = h;
|
||||
}
|
||||
async function sendKey(code, press, ms) {
|
||||
try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); }
|
||||
catch (e) { toast('发送失败:' + errText(e)); }
|
||||
}
|
||||
function bindRemote() {
|
||||
$('#content').addEventListener('click', function (e) {
|
||||
const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; }
|
||||
const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; }
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 7. 系统信息 / OTA 页 ---------------- */
|
||||
async function renderSystem() {
|
||||
$('#content').innerHTML = '<div class="page" id="system-page"><p class="placeholder">加载系统信息…</p></div>';
|
||||
let d;
|
||||
try { d = await api('GET', '/api/system'); }
|
||||
catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||
if (state.route !== 'system') return;
|
||||
const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {};
|
||||
let h = '<div class="page" id="system-page">';
|
||||
h += '<div class="card"><h3 class="card-title">系统信息</h3><table class="table"><tbody>' +
|
||||
row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) +
|
||||
row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec)) +
|
||||
row('默认启动', esc((d.boot && d.boot.default) || '—')) +
|
||||
'</tbody></table></div>';
|
||||
h += '<div class="card"><h3 class="card-title">网络</h3><table class="table"><tbody>' +
|
||||
row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') +
|
||||
row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') +
|
||||
row('mDNS', esc(net.hostname_local || '—')) +
|
||||
'</tbody></table></div>';
|
||||
h += '<div class="card"><h3 class="card-title">升级</h3>' +
|
||||
'<p class="muted">当前 ' + esc(ota.current || d.version || '—') +
|
||||
(ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '</p>' +
|
||||
'<div class="field-row" style="gap:10px"><button class="btn btn-primary" id="sys-ota-check">检查升级</button>' +
|
||||
'<button class="btn btn-danger" id="sys-ota">⚠️ 升级</button></div></div>';
|
||||
h += '<div class="card danger-box"><h3 class="card-title">危险操作</h3>' +
|
||||
'<div class="field-row" style="gap:10px"><button class="btn btn-danger" id="sys-reboot">重启</button>' +
|
||||
'<button class="btn btn-danger" id="sys-shutdown">关机</button></div>' +
|
||||
'<p class="warn-note">⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。</p></div>';
|
||||
h += '</div>';
|
||||
$('#content').innerHTML = h;
|
||||
}
|
||||
function row(k, v) { return '<tr><td class="muted" style="width:120px">' + k + '</td><td>' + v + '</td></tr>'; }
|
||||
function bindSystem() { // init 一次性委托绑定(不随渲染累积)
|
||||
$('#content').addEventListener('click', async function (e) {
|
||||
if (e.target.closest('#sys-reboot')) {
|
||||
if (await confirmDlg('确认重启', '系统将立即重启,请在浏览器中关闭本页。', true))
|
||||
try { await api('POST', '/api/system/reboot', {}); toast('已请求重启'); } catch (err) { toast('重启失败:' + errText(err)); }
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#sys-shutdown')) {
|
||||
if (await confirmDlg('确认关机', '系统将立即关机,请在浏览器中关闭本页。', true))
|
||||
try { await api('POST', '/api/system/shutdown', {}); toast('已请求关机'); } catch (err) { toast('关机失败:' + errText(err)); }
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#sys-ota')) {
|
||||
if (await confirmDlg('确认升级', '⚠️ 升级中断可能导致系统无法启动,请确保已连接电源。确认开始升级?', true))
|
||||
try { await api('POST', '/api/system/ota', {}); toast('已触发升级任务'); } catch (err) { toast('升级失败:' + errText(err)); }
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#sys-ota-check')) {
|
||||
// 检查升级只读,用 GET /api/system 读 ota.available/latest;勿 POST /api/system/ota(那是触发升级)
|
||||
try { const r = await api('GET', '/api/system'); const o = r.ota || {}; toast(o.available ? '发现新版本 ' + o.latest : '已是最新版本'); }
|
||||
catch (err) { toast('检查失败:' + errText(err)); }
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 8. 日志页 ---------------- */
|
||||
async function renderLogs() {
|
||||
$('#content').innerHTML = '<div class="page" id="logs-page"><p class="placeholder">加载日志列表…</p></div>';
|
||||
let d;
|
||||
try { d = await api('GET', '/api/logs'); }
|
||||
catch (e) { if (state.route === 'logs') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||
if (state.route !== 'logs') return;
|
||||
let h = '<div class="page" id="logs-page">';
|
||||
h += '<div class="card"><h3 class="card-title">日志文件(MeowOS/logs)</h3>';
|
||||
h += '<div class="field-row" style="margin-bottom:12px"><span class="muted">' + esc(d.dir || '') + '</span></div>';
|
||||
h += '<table class="table"><thead><tr><th>文件</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
|
||||
if (!d.files.length) h += '<tr><td colspan="4" class="empty">暂无日志</td></tr>';
|
||||
d.files.forEach(f => {
|
||||
h += '<tr><td class="mono">' + esc(f.name) + '</td><td class="muted">' + fmtBytes(f.size) + '</td><td class="muted">' + fmtTime(f.mtime) + '</td>' +
|
||||
'<td><div class="row-actions"><button class="btn btn-ghost" data-log="' + esc(f.name) + '">查看</button></div></td></tr>';
|
||||
});
|
||||
h += '</tbody></table></div><pre class="log-view" id="log-view" hidden></pre></div>';
|
||||
$('#content').innerHTML = h;
|
||||
}
|
||||
function bindLogs() {
|
||||
$('#content').addEventListener('click', async function (e) {
|
||||
const t = e.target.closest('[data-log]'); if (!t) return;
|
||||
const p = $('#log-view');
|
||||
try {
|
||||
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(t.dataset.log) + '&lines=400');
|
||||
p.hidden = false; p.textContent = (d.lines || []).join('\n') + (d.truncated ? '\n…(更多行被截断)' : '');
|
||||
} catch (err) { toast('查看失败:' + errText(err)); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 9. 通知页 ---------------- */
|
||||
async function renderNotif() {
|
||||
$('#content').innerHTML = '<div class="page" id="notif-page"><p class="placeholder">加载通知…</p></div>';
|
||||
let d;
|
||||
try { d = await api('GET', '/api/notifications'); }
|
||||
catch (e) { if (state.route === 'notif') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; }
|
||||
if (state.route !== 'notif') return;
|
||||
let h = '<div class="page" id="notif-page">';
|
||||
h += '<div class="field-row" style="margin-bottom:14px"><button class="btn" id="notif-refresh">刷新</button>' +
|
||||
'<button class="btn btn-danger" id="notif-clear">清空通知</button></div>';
|
||||
const list = d.notifications || [];
|
||||
if (!list.length) h += '<div class="empty">暂无通知</div>';
|
||||
list.forEach(n => {
|
||||
const urg = n.urgency >= 2 ? '高' : (n.urgency === 1 ? '中' : '低');
|
||||
h += '<div class="notif" data-id="' + esc(n.id) + '"><div class="n-ico">' + esc((n.app_name || '?').charAt(0).toUpperCase()) + '</div>' +
|
||||
'<div class="n-body"><div class="n-title">' + esc(n.summary || n.app_name || '通知') + '</div>' +
|
||||
(n.body ? '<div class="n-text">' + esc(n.body) + '</div>' : '') +
|
||||
'<div class="n-meta"><span>' + esc(n.app_name || '') + '</span><span>' + fmtTime(n.ts) + '</span>' +
|
||||
'<span class="' + (n.urgency >= 2 ? 'urgency-hi' : '') + '">紧急度 ' + urg + '</span>' +
|
||||
(n.progress != null ? '<span>进度 ' + n.progress + '%</span>' : '') +
|
||||
(n.ongoing ? '<span>进行中</span>' : '') + '</div></div>' +
|
||||
'<button class="btn btn-ghost" data-ndel="' + esc(n.id) + '">清除</button></div>';
|
||||
});
|
||||
h += '</div>';
|
||||
$('#content').innerHTML = h;
|
||||
}
|
||||
function bindNotif() {
|
||||
$('#content').addEventListener('click', async function (e) {
|
||||
if (e.target.closest('#notif-refresh')) { renderNotif(); return; }
|
||||
if (e.target.closest('#notif-clear')) {
|
||||
if (await confirmDlg('清空通知', '将清空全部通知。确认?', true))
|
||||
try { await api('POST', '/api/notifications/dismiss_all'); toast('已清空'); renderNotif(); }
|
||||
catch (err) { toast('清空失败:' + errText(err)); }
|
||||
return;
|
||||
}
|
||||
const del = e.target.closest('[data-ndel]'); if (del) {
|
||||
try { await api('DELETE', '/api/notifications/' + del.dataset.ndel); toast('已清除'); renderNotif(); }
|
||||
catch (err) { toast('清除失败:' + errText(err)); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 路由 ---------------- */
|
||||
const ROUTES = {
|
||||
perf: { title: '实时性能', render: renderPerf, bind: null },
|
||||
bright: { title: '亮度 · 音量', render: renderBright, bind: null },
|
||||
files: { title: '文件管理', render: renderFiles, bind: bindFiles },
|
||||
apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps },
|
||||
settings: { title: '设置镜像', render: renderSettings, bind: bindSettings },
|
||||
remote: { title: '远程控制', render: renderRemote, bind: bindRemote },
|
||||
system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem },
|
||||
logs: { title: '系统日志', render: renderLogs, bind: bindLogs },
|
||||
notif: { title: '通知中心', render: renderNotif, bind: bindNotif }
|
||||
};
|
||||
// 一次性绑定(各菜单 click / 侧栏);页面内交互用 #content 事件委托(已各自绑定)
|
||||
function bindNav() {
|
||||
$$('.nav-item').forEach(b => b.addEventListener('click', () => { location.hash = '#/' + b.dataset.route; }));
|
||||
$('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open'));
|
||||
}
|
||||
function route() {
|
||||
const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf';
|
||||
const r = ROUTES[id] || ROUTES.perf;
|
||||
state.route = id;
|
||||
stopPerf();
|
||||
$$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id));
|
||||
$('#page-title').textContent = r.title;
|
||||
r.render();
|
||||
}
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
function init() {
|
||||
bindNav();
|
||||
bindThemeUi();
|
||||
bindSlider();
|
||||
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif();
|
||||
initTheme();
|
||||
window.addEventListener('hashchange', route);
|
||||
route();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
Reference in new issue
Block a user