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 @@ +

English | 简体中文

+ +# 开发指南 + +本指南覆盖 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` 文件,作为 `