diff --git a/linkease-common-bin/Makefile b/linkease-common-bin/Makefile
index c98d83b7..47f1617e 100644
--- a/linkease-common-bin/Makefile
+++ b/linkease-common-bin/Makefile
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease-common-bin
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5
-PKG_RELEASE:=11
+PKG_RELEASE:=12
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c
endif
-PKG_SOURCE_VERSION:=b5611e40da63fec2b6a0bb93b272fe0fdc704370
+PKG_SOURCE_VERSION:=731629bd526b5215e0a0ad39eb740d45bb458ec5
PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
diff --git a/linkease/Makefile b/linkease/Makefile
index 9ae7345a..4f505e91 100644
--- a/linkease/Makefile
+++ b/linkease/Makefile
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5
-PKG_RELEASE:=14
+PKG_RELEASE:=15
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c
endif
-PKG_SOURCE_VERSION:=b5611e40da63fec2b6a0bb93b272fe0fdc704370
+PKG_SOURCE_VERSION:=731629bd526b5215e0a0ad39eb740d45bb458ec5
PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
diff --git a/linkeasefull/Makefile b/linkeasefull/Makefile
index 2490da45..39e064f0 100644
--- a/linkeasefull/Makefile
+++ b/linkeasefull/Makefile
@@ -11,7 +11,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkeasefull
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=3.0.11
-PKG_RELEASE:=13
+PKG_RELEASE:=14
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64
PKG_HASH:=skip
endif
-PKG_SOURCE_VERSION:=b5611e40da63fec2b6a0bb93b272fe0fdc704370
+PKG_SOURCE_VERSION:=731629bd526b5215e0a0ad39eb740d45bb458ec5
PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH)
diff --git a/luci-base/htdocs/luci-static/resources/network.js b/luci-base/htdocs/luci-static/resources/network.js
index 8afe4974..16ce9f90 100644
--- a/luci-base/htdocs/luci-static/resources/network.js
+++ b/luci-base/htdocs/luci-static/resources/network.js
@@ -405,6 +405,16 @@ function refreshWirelessState() {
return _wirelessInit;
}
+function waitForWirelessState() {
+ const wifiDevices = uci.sections('wireless', 'wifi-device');
+ const hasQcaWifi = wifiDevices.some(function(device) {
+ return (device.type == 'qcawifi' || device.type == 'qcawificfg80211');
+ });
+ const refresh = refreshWirelessState();
+
+ return hasQcaWifi ? Promise.resolve() : refresh;
+}
+
function initNetworkState(refresh) {
if (_state == null || refresh) {
const hasWifi = L.hasSystemFeature('wifi');
@@ -1401,9 +1411,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* be found.
*/
getWifiDevice(devname) {
- return initNetworkState().then(L.bind(function() {
- refreshWirelessState();
-
+ return initNetworkState().then(waitForWirelessState).then(L.bind(function() {
const existingDevice = uci.get('wireless', devname);
if (existingDevice == null || existingDevice['.type'] != 'wifi-device')
@@ -1423,9 +1431,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* the configuration.
*/
getWifiDevices() {
- return initNetworkState().then(L.bind(function() {
- refreshWirelessState();
-
+ return initNetworkState().then(waitForWirelessState).then(L.bind(function() {
const uciWifiDevices = uci.sections('wireless', 'wifi-device');
const rv = [];
@@ -1469,9 +1475,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* be found.
*/
getWifiNetwork(netname) {
- return initNetworkState().then(L.bind(function() {
- refreshWirelessState();
-
+ return initNetworkState().then(waitForWirelessState).then(L.bind(function() {
return this.lookupWifiNetwork(netname);
}, this));
},
@@ -1486,9 +1490,7 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ {
* are found.
*/
getWifiNetworks() {
- return initNetworkState().then(L.bind(function() {
- refreshWirelessState();
-
+ return initNetworkState().then(waitForWirelessState).then(L.bind(function() {
const wifiIfaces = uci.sections('wireless', 'wifi-iface');
const rv = [];
diff --git a/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js b/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js
index ced845b9..734f6df5 100644
--- a/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js
+++ b/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js
@@ -2556,12 +2556,33 @@ return view.extend({
s.addremove = false;
s.load = function() {
- this.radios = network.getWifiDevicesFromConfig().sort(function(a, b) {
+ const configuredRadios = network.getWifiDevicesFromConfig().sort(function(a, b) {
return a.getName() > b.getName();
});
- this.wifis = network.getWifiNetworksFromConfig();
+ const hasQcaWifi = configuredRadios.some(function(radio) {
+ return isQcaWifiHwtype(uci.get('wireless', radio.getName(), 'type'));
+ });
- return Promise.resolve();
+ if (hasQcaWifi) {
+ this.radios = configuredRadios;
+ this.wifis = network.getWifiNetworksFromConfig();
+ return Promise.resolve();
+ }
+
+ return network.getWifiDevices().then(L.bind(function(radios) {
+ this.radios = radios.sort(function(a, b) {
+ return a.getName() > b.getName();
+ });
+
+ return Promise.all(radios.map(function(radio) {
+ return radio.getWifiNetworks();
+ }));
+ }, this)).then(L.bind(function(networks) {
+ this.wifis = [];
+
+ for (const radioNetworks of networks)
+ this.wifis.push.apply(this.wifis, radioNetworks);
+ }, this));
};
s.cfgsections = function() {
diff --git a/luci-theme-aurora/.dev/docs/DEVELOPMENT.md b/luci-theme-aurora/.dev/docs/DEVELOPMENT.md
index bca39375..fa175db8 100644
--- a/luci-theme-aurora/.dev/docs/DEVELOPMENT.md
+++ b/luci-theme-aurora/.dev/docs/DEVELOPMENT.md
@@ -1,3 +1,5 @@
+
English | 简体中文
+
# Development Guide
This guide covers the complete development workflow for the Aurora theme, from environment setup to building production packages.
diff --git a/luci-theme-aurora/.dev/docs/DEVELOPMENT_zh.md b/luci-theme-aurora/.dev/docs/DEVELOPMENT_zh.md
new file mode 100644
index 00000000..b7e606b9
--- /dev/null
+++ b/luci-theme-aurora/.dev/docs/DEVELOPMENT_zh.md
@@ -0,0 +1,395 @@
+
+
+# 开发指南
+
+本指南覆盖 Aurora 主题从环境搭建到构建生产包的完整开发流程。
+
+## 前置条件
+
+- **[Node.js 20.19+ / 22.12+](https://nodejs.org/en/download)** —— JavaScript 运行时(与 Vite 7 的支持范围一致 —— 21.x 这类奇数版本不符合要求;在 `pnpm install` 时通过 `engines` + `engine-strict` 强制执行,因此不受支持的 Node 会立刻失败并给出清晰提示)
+- **pnpm** —— 包管理器(通过 [Corepack](https://github.com/nodejs/corepack) 管理版本)
+- **Tailwind CSS 知识** —— 写样式必备。参见 [Tailwind CSS 文档](https://tailwindcss.com/docs)
+- **网络连通** —— 开发机必须与你的 OpenWrt 路由器处在同一网络
+
+## 环境搭建
+
+### 1. 克隆与安装
+
+```bash
+# 克隆仓库
+git clone git@github.com:eamonxg/luci-theme-aurora.git
+cd luci-theme-aurora/.dev/
+
+# 启用 Corepack 来管理 pnpm 版本
+corepack enable && corepack prepare
+
+# 安装依赖
+pnpm install
+```
+
+### 2. 配置环境
+
+```bash
+# 一站式向导:询问每一个 .env 值(路由器 IP、开发服务器 host/port,
+# 以当前 .env 条目为默认值),生成/安装 SSH 密钥(只提示一次路由器密码),
+# 端到端验证模板同步,然后写入 .env
+pnpm setup:router
+
+# 非交互式:直接传入 IP(不再提问;开发服务器相关的值保持
+# 已保存的 .env 条目或默认值)
+pnpm setup:router 192.168.2.1
+```
+
+> 脚本名叫 `setup:router` 而不是 `setup`,因为 `pnpm setup` 会解析到 pnpm 自带的内置 setup 命令,永远不会执行包脚本。
+
+**它做了什么**(`scripts/setup.js`),按顺序:
+
+1. **收集 `.env` 的值。** 不带参数时会逐个变量提问,并把当前 `.env` 条目作为默认值展示(回车留空即保持原值)。带 IP 参数时则非交互地把它当作 `VITE_OPENWRT_HOST`,开发服务器相关的值原样不动。此时还什么都没写 —— 只有下面每一步都成功后,`.env` 才会被更新。
+2. **预检连接。** 对 `:22` 做一次超时 2 秒的原始 TCP 探测,这样不可达的设备、或关闭了 SSH 的设备会立刻带着清晰提示失败,而不是卡在 ssh 里面。
+3. **找到或生成 SSH 密钥。** 它按顺序查找 `~/.ssh/id_ed25519.pub`、`id_rsa.pub`、`id_ecdsa.pub`,复用第一个存在的 —— 已有密钥绝不会被覆盖。**如果你完全没有密钥,它会生成一个**(`ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519`)。空口令是刻意的:`.ut` 同步必须在整个 `pnpm dev` 会话期间无人值守地运行。
+4. **把公钥装到路由器上。** 它先测试免密 SSH 是否已经可用(`ssh -o BatchMode=yes … echo ok`),可用就跳过安装 —— 而在一次抹掉密钥的完整刷机之后,该测试会失败,安装便会重新执行。否则它会在一次交互式 SSH 会话中把密钥追加到 `/etc/dropbear/authorized_keys`;追加由 `grep -qxF` 守卫,所以重复运行不会产生重复条目。**这是唯一一个会询问路由器 root 密码的步骤,而且只问一次。** 之后它会重新测试免密认证,并对任何失败进行归类(密码错误 / 主机不可达 / 其他)。如果设备跑的是 openssh 而不是 dropbear,密钥应该放在 `/root/.ssh/authorized_keys` —— 手动装到那里,本步骤会检测到并跳过。
+5. **端到端验证同步。** 它不满足于 `echo ok`,而是用 `ut-sync` 插件所使用的完全相同的 `tar -cf - | ssh … tar -xf -` 管道把 `ucode/template/themes/aurora/` 推上去(见 [模板(`.ut`)实时同步](#模板ut实时同步))。这一步通过了,`pnpm dev` 的模板同步也就通过了。
+6. **写入 `.env`。** 受管理的键在原位重写,周围的注释因此保持意义;缺失的键追加到末尾;其他任何行原样透传。
+
+> 第 4 步的密码提示来自**本地**的 ssh 客户端,它直接从 `/dev/tty` 读取而不是 stdin —— 所以即便脚本为了归类错误而管道化了 ssh 的 stderr,提示仍会正常显示。但这也意味着该步骤需要**开发机上**有一个控制终端(路由器一侧不受影响):请在普通 shell 里运行,而不要在 CI、`nohup` 或编辑器任务面板里运行,那些环境下 ssh 无从提问,运行会在认证处失败。不确定就用 `tty` 检查;在这类环境下,请改为手动安装密钥。
+
+如果路由器就在默认的 `192.168.1.1` 上、且免密 SSH 已经可用,那么完全不需要 `.env` —— 下面每一个值都有可用的默认值。
+
+**环境变量**(全部可选):
+
+- `VITE_OPENWRT_HOST` —— 路由器裸地址,例如 `192.168.1.1`(默认值;也接受 `host:port` 和完整 URL 形式)。Web 代理目标和 `.ut` 同步的 SSH 目标(`root@`)都由它推导。更花哨的需求(专用密钥、跳板机、非标准 ssh 端口)应写进 `~/.ssh/config` 的 `Host` 块,ssh 会自动读取。
+- `VITE_DEV_HOST` —— 开发服务器 host(代码默认值 `127.0.0.1`,`.env.example` 里设为 `0.0.0.0` 以便局域网访问)
+- `VITE_DEV_PORT` —— 开发服务器端口(默认 `5173`)
+
+## 开发流程
+
+### 启动开发服务器
+
+```bash
+cd luci-theme-aurora/.dev/
+pnpm dev
+```
+
+开发服务器会在 `http://127.0.0.1:5173` 启动,并把请求代理到你的 OpenWrt 设备。
+
+**Vite 代理的工作方式:**
+
+Vite 开发服务器用中间件重写本地请求,使 CSS/JS 资源由你的开发环境提供,而不是由路由器提供。这样就能在不部署到路由器的前提下实时编辑。具体实现见 `vite.config.ts`。
+
+**代理的关键行为:**
+
+1. 把 `/cgi-bin` 和 `/luci-static` 请求代理到 OpenWrt 设备
+2. 用中间件(`createLocalServePlugin`)重写 CSS 和 JS 文件的请求路径
+3. 对 `/luci-static/aurora/main.css` 和 `/luci-static/aurora/login.css` 的 CSS 请求分别被重写为由 `.dev/src/media/main.css` 和 `.dev/src/media/login.css` 提供
+4. JS 文件请求直接由 `.dev/src/resource/` 提供,中间件读取文件内容并返回
+5. 向被代理的 HTML 响应注入 Vite HMR 客户端以支持实时重载
+6. 把 `/` 重定向到 `/cgi-bin/luci` 以正确路由
+
+### 代码风格与格式化
+
+本项目使用 **Prettier** 进行代码格式化,并开启保存时自动格式化。
+
+**Prettier 配置:**
+
+- 位于 `.prettierrc`
+- `.vscode/settings.json` 中的 VS Code 设置为 CSS 和 JS 文件启用保存时格式化
+- 使用 `prettier-plugin-tailwindcss` 排序 Tailwind CSS 类名
+
+### CSS 嵌套支持
+
+得益于 **lightningcss**,你可以在样式表中自由使用 [CSS 嵌套语法](https://drafts.csswg.org/css-nesting/)。构建过程会自动把嵌套 CSS 编译成扁平的、浏览器兼容的形式。
+
+它会被编译成在所有浏览器中都能工作的标准 CSS。
+
+### CSS 架构
+
+主题有两个彼此独立的 Tailwind CSS v4 入口,均来自 `.dev/src/media/`:
+
+- **`main.css`** —— LuCI 管理界面。它是一份 import 清单,禁用了 Tailwind 的自动源码扫描(`source(none)`),并按顺序引入 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`(OKLCH 主题 token,经 `@theme inline` 映射)、共享的 `_icons.css`、`_base.css`、`_elements.css`、`_layout.css`、`components/` 下的每一个文件(每个 UI 组件一个片段 —— 按钮、卡片、弹窗、表格等等),以及 `_utilities.css`。
+- **`login.css`** —— 独立的登录页(`sysauth.ut`)。自包含:用 `source(none)` 引入 Tailwind 的 theme/utilities,不用完整 Preflight 而改用一份极小的本地 reset,并直接引入 `@eamonxg/luci-theme-tokens/dist/aurora/tokens.css`。构建时 `login-css-prune` 插件(`vite.config.ts`)会剥掉该页 var() 链条永远够不到的每一个自定义属性,于是管理端尺寸的 token 表以登录页的尺寸发布。
+
+第三方兼容补丁**不会**被打进 `main.css` —— 它们被拆成 `media/patches/` 下的按页文件并按需加载(见下文 [按需第三方补丁](#按需第三方补丁))。
+
+**新增样式:**
+
+- 新的 UI 组件 → 创建 `components/_.css` 并在 `main.css` 中加一行 `@import`。每个文件都是自己的组织单元 —— 不要加 `@layer` 包裹:主题片段保持不分层,因此无论特异性如何都压过 Tailwind 分层的 base/utilities。
+- 针对第三方 LuCI 应用/页面的兼容修复 → 在 `media/patches/` 下新建一个文件(见下文)。
+
+所有规则都用 `@apply` 配合 Tailwind 工具类和 CSS 嵌套 —— 不写裸 CSS 属性。唯一刻意的例外是 `media/patches/*.css`,它按设计就是原生 CSS(见下文)。
+
+### 按需第三方补丁
+
+> 页面级 JS 补丁必须暴露 `window.aurora.patches[stem] = { mount, unmount }`(并在求值时自行挂载一次),这样客户端路由才能在同文档导航之间驱动它们 —— 见 `router_zh.md`。
+
+有些第三方 LuCI 应用发布的标记结构无法适配主题,需要一处狭窄的兼容覆盖。与其把每一个这样的补丁都打进 `main.css`(那会把它们发到**每一个**页面),不如让每个补丁成为一个独立 CSS 文件,**只在它所针对的页面上加载**。
+
+**工作方式:**
+
+1. **一页一文件,以 `data-page` 命名。** 每个补丁位于 `media/patches/.css`,其中 `` 是目标页面 `` 的值 —— 也就是请求路径各段用 `-` 连接的结果(例如 `admin-services-openclash-config`)。`header.ut` 在渲染时从 `ctx.request_path` 计算出同样的字符串,`request_path` 为空时回退到 `ctx.path`(`join('-', length(ctx.request_path) ? ctx.request_path : ctx.path)`),这样不带显式路径抵达的默认落地页也能解析出自己的补丁。
+2. **构建是拆分,不是打包。** `vite.config.ts` 把每个 `media/patches/*.css` 都加为独立的 Rollup 入口,因此各自编译成 `htdocs/luci-static/aurora/patches/.css`。它们不再是 `main.css` 的一部分。
+3. **原生 CSS,不用 `@apply`。** 补丁是唯一写原生声明而非 Tailwind 工具类的地方。每个补丁都是独立构建入口,而旧的 `@reference "../main.css";` + `@apply` 方案会让每个文件各自携带一份 `@property`/辅助样板。把*同样的规则*改写成原生 —— 基于 v1.1.7 → v1.1.8 的转换实测,构建产物,选择器和取值完全相同:
+
+ | 补丁 | `@reference` + `@apply` | 原生 CSS | 节省 |
+ | --- | ---: | ---: | ---: |
+ | admin-dashboard | 6,940 B | 2,639 B | −62% |
+ | admin-modem-modemdata-modempreview | 162 B | 96 B | −41% |
+ | admin-modem-qmodem | 9,483 B | 4,497 B | −53% |
+ | admin-network-network | 160 B | 86 B | −46% |
+ | admin-services-openclash-config | 187 B | 87 B | −53% |
+ | admin-services-openclash-settings | 638 B | 509 B | −20% |
+ | admin-statistics-graphs | 1,609 B | 110 B | −93% |
+ | admin-system-filemanager | 576 B | 184 B | −68% |
+ | **合计** | **19,755 B** | **8,208 B** | **−58%** |
+
+ 这份开销是按入口计的、且大体固定(`@property` 注册、`--tw-*` 辅助链),所以补丁越小、比例越难看 —— 只有两条规则的 statistics 补丁付出了自身重量 15 倍的代价。uhttpd 按原样字节提供服务(不做 gzip),所以原始体积就是传输体积。注意运行时的 `:root` 暴露的是原始颜色 token 加上 `--radius-base` 和 `--app-shadow-*` —— `--radius-3xl` 这类名字只存在于 Tailwind 构建内部,所以补丁要写 `calc(var(--radius-base) * 3)` 来取圆角,绝不要写 `var(--radius-3xl)`。对主题仓库自己的补丁而言,旧的 Tailwind 模式仍然*受支持* —— 以 `@reference "../main.css";` 开头并使用 `@apply` 的文件照样能编译 —— 而且它有几个值得与上表权衡的实在好处:**构建期校验**(写错的工具类或颜色名会让构建失败,而写错的 `var()` 只会在运行时静默失效 —— `--radius-3xl` 那次回归正是这样溜进原生改写版的)、与组件片段**同一套词汇**以及 `dark:`/`md:`/`hover:` 变体简写,还有主题重新映射某个工具类时的**自动跟随**(圆角链或阴影 token 的改动会免费重新编译进 Tailwind 补丁;原生补丁则需要手动改)。原生是为了上面那组体积数字而定的默认约定,不是硬性门槛。由应用自带的补丁(直接装进设备的 `patches/` 目录)从来就没有选择余地:它们完全绕过构建,`@apply` 到浏览器就是一段无效文本 —— 一如既往,只能写原生 CSS。这也意味着第三方补丁作者不需要懂 Tailwind。请通过 `:root` 自定义属性取用主题值(`var(--surface-sunken)`、`var(--hairline)`、`var(--radius-3xl)`、`var(--shadow-lg)` 等),不要硬编码颜色或圆角;深色变体是一个普通选择器(`[data-darkmode="true"] & { … }`),断点是普通媒体查询(`@media (width < 48rem)`),CSS 嵌套依旧可用 —— 构建(lightningcss)会为受支持的浏览器压缩并降级它。
+4. **`header.ut` 在渲染时发现补丁。** 每次(非登录页)渲染时,`header.ut` 用 ucode 的 `fs.lsdir()` 列出 `/www/luci-static/aurora/patches/`(读取十来个条目的 readdir —— 微秒级,被模板已有的 `ubus` 调用完全盖过),并把已安装的 `*.css` 文件名与请求的**累积路径段前缀**匹配:一个补丁匹配它的精确页面和任意子页面,但只在真正的段边界上 —— `admin-services-wol.css` 覆盖 `admin/services/wol/plus`,却绝不会覆盖某个自身段只是恰好以同样字符开头的兄弟应用(`admin/services/wol-plus`)。每个匹配到的补丁都会紧跟在 `main.css` 之后链接,按字典序排列 —— 于是通用补丁先加载、更具体的后加载,具体的那个在层叠中压在上面。没有匹配的页面什么都不会拿到 —— 没有额外请求,也没有 404。如果目录缺失或不可读,列表为空,页面就以未打补丁的状态渲染。
+5. **补丁目录是一个即插即用的扩展点。** 由于发现发生在渲染时,补丁不必随主题发布:**任何包都可以把一个 `.css` 装进 `/www/luci-static/aurora/patches/`**,主题就会在匹配的页面上加载它。安装/卸载的生命周期是自动的 —— 文件随包出现和消失,无需注册,也无需重建白名单。(以这种方式发布的补丁按原样作为纯 CSS 提供;主题自己的补丁也是同样写法,只是额外过一遍构建做压缩。)
+6. **动态生成的页面由它们的固定前缀覆盖。** 有些应用为每个实体铸造一个页面 —— 例如 QModem 的短信会话渲染为 `admin-modem-qmodem-sms-conversation-`。把补丁按固定前缀命名(`admin-modem-qmodem-sms-conversation.css`),前缀匹配就会为每个会话页面加载它,与联系人名字无关。不需要通配符语法(文件名里的 `*` 也不受支持)。
+
+**添加一个补丁:**
+
+1. 在浏览器里打开目标页面并读取 `document.body.dataset.page` —— 那个精确字符串就是你的文件名(对于一族动态的按实体页面,改用它们的固定前缀 —— 见上文第 5 点)。
+2. 创建 `media/patches/<那个字符串>.css`:
+ ```css
+ /* PATCH: (luci-app-foo) */
+
+ [data-page=""] {
+ /* 狭窄的、限定在选择器内的覆盖 —— 原生 CSS + CSS 嵌套,
+ 主题值通过 var(--surface)、var(--hairline)、… 取用 */
+ }
+ ```
+3. 运行 `pnpm build`。没有白名单需要重新生成 —— 加载器会在渲染时发现 `patches/` 下安装了哪些 `.css` 文件。
+4. 确认 `htdocs/luci-static/aurora/patches/.css` 很小(只有你写的规则)。
+
+> 移除补丁是对称的:删掉文件并重新构建 —— 加载器不再链接它,因为它不存在了。
+
+**随第三方应用一起发布补丁**(无需主题发版):构建或手写一个以你页面 `data-page` 前缀命名的纯 CSS 文件,并在你的包 Makefile 中安装它:
+
+```makefile
+define Package/luci-app-foo/install
+ ...
+ $(INSTALL_DIR) $(1)/www/luci-static/aurora/patches
+ $(INSTALL_DATA) ./htdocs/aurora-patch.css \
+ $(1)/www/luci-static/aurora/patches/admin-services-foo.css
+endef
+```
+
+只要两个包都安装了,主题就会在 `admin-services-foo` 及其所有子页面上自动加载它。注意应用自带的补丁绕过了主题的 Tailwind 构建 —— 请写纯 CSS(你仍然可以引用主题的 CSS 自定义属性,例如 `var(--surface)`),并把每条规则都限定在你自己的 `[data-page^="…"]` 选择器之下。
+
+**命名。** 文件名就是页面的 `data-page` 字符串;因为按前缀匹配,更宽的目标也自然生效:
+
+| 你想打补丁的对象… | 要创建的文件 | 它会在哪些页面加载 |
+| --- | --- | --- |
+| 某个具体页面,`admin/services/foo/general` | `admin-services-foo-general.css` | 该页面(及其下任意子页面) |
+| 整个应用,`admin/services/foo/…` 下所有页面 | `admin-services-foo.css` | `foo`、`foo/general`、`foo/rules`、… |
+| 动态的按实体页面,例如 QModem 短信 `…/sms/conversation/` | `admin-modem-qmodem-sms-conversation.css`(固定前缀 —— 无需通配符) | 每一个会话页面,不论联系人是谁 |
+
+由前缀匹配自然引出两条经验法则:
+
+- **补丁默认作用于它的页面和所有子页面。** `admin-services-foo.css` 会在 `admin/services/foo/…` 下的每个页面加载。需要更精确的目标时,有两种收窄方式:在文件内部限定单条规则的作用域(`[data-page="admin-services-foo-general"] { … }` 只影响那一个页面),或者为页面专属规则再发布一个名字更长的文件(`admin-services-foo-rules.css`)—— 在两者都匹配的页面上,**两个都会加载**,名字短的在前,因此更具体的那个赢得层叠。
+- **匹配遵守路径段边界**,所以前缀绝不会泄漏到长得像的兄弟上:`admin-services-wol.css` 覆盖 `admin/services/wol/plus`,但不覆盖位于 `admin/services/wol-plus` 的另一个应用。唯一无法避免的冲突是两条路径拼接出同一个 `data-page` 字符串(`wol/plus` vs `wol-plus`)—— 这样的补丁会在两个页面上都加载。如果这有影响,就把规则挂到你自己应用的类名/id 上,这样意外加载也匹配不到任何东西。
+
+> 与 `_` 前缀的片段(那些是只供 `@import` 的碎片)不同,补丁文件名没有 `_` 前缀 —— 每一个都是会发布到 `htdocs/` 的真实构建入口。这一点在 `media/patches/` 内部同样成立:那里以 `_` 开头的文件是供其他补丁 `@import` 的共享片段,会被入口扫描跳过,永不发布。
+
+**JS 负载。** 这套机制并不限于 CSS:同一次 `lsdir()` 扫描也会收集 `patches/.js` 文件,作为 `