fix(webui): 服务入口「打开」改用路径形态 + 别名模式不补尾斜杠

验收时发现的**用户可见缺口**:API 早就同时返回 url(子域)与 url_portal
(路径),但「服务入口」卡片只用了 url —— 而子域形态在穿透部署下
**恰恰是打不开的那个**(外层只放行一个 Host、三级子域通配证书不匹配)。
用户点「打开」得到坏链接,还会以为是插件的问题。

## 改动

1. 卡片「打开」优先 url_portal(路径形态):
   无 DNS 依赖,单端口穿透 / 子域无证书时都能用。
   子域链接保留为次选按钮(局域网内直连时更直观)。
   文案补一句说明两者差别(子域需 DNS 能解析 `*.<基域名>`)。

2. **别名模式不再补尾斜杠**(顺带发现的 bug):
   原实现给所有 url_portal 无条件加 `/`。前缀模式下对(那是规范形态,
   前端靠它算相对路径基准);别名模式下错 —— 那里的 path 是上游真实
   路径语义(/api/v1/device 是 /api/v1/device/xxx 的前缀),补成
   /api/v1/device/ 会让人误以为存在一个可访问的根。

## 判据

+2 条:TestProxyServicesOffersBothForms(两种形态都必须给出,
且前缀模式的 url_portal 必须带尾斜杠)、
TestProxyServicesAliasKeepsExactPath(别名模式不得带尾斜杠)。

变异验证(2 条,均按预期打红后还原回绿):
- 别名模式也加尾斜杠 → 判红
- 前缀模式不加尾斜杠 → 判红

另修正一条旧判据的期望值:它当年断言的是「所有 url_portal 都带尾斜杠」
(即把 bug 当成契约钉住了)。那条路由正是设备网关(别名模式),
现在改为断言不补尾斜杠,并注明理由。

全量:35 包全绿。
This commit is contained in:
JianFeeeee
2026-09-25 17:25:08 +08:00
parent dfcfe3593d
commit 428f148374
3 changed files with 151 additions and 8 deletions

View File

@ -3835,9 +3835,17 @@
var token = state.settings?.["plugin.webui.api_key"] || "";
html +=
'<div style="font-size:12px;color:var(--text-muted);margin-bottom:10px">' +
__("插件服务经 HomeAgent 同一端口反代,按子域区分(基域名 ", "Proxied through the same port, keyed by subdomain (base ") +
escHtml(base) +
__(")。点「打开」直接访问。", "). Click Open to visit.") +
__(
"插件服务经 HomeAgent 同一端口反代。子域形态需要 DNS 能解析 ",
"Proxied through the same port. The subdomain form needs DNS for ",
) +
"<b>" +
escHtml("*." + base) +
"</b>" +
__(
";路径形态无 DNS 依赖,穿透场景下更可靠(点「打开」优先用它)。",
"; the path form has no DNS dependency and is more reliable behind a tunnel (Open prefers it).",
) +
"</div>";
html += '<div class="svc-list">';
svcs.forEach(function (s) {
@ -3858,13 +3866,30 @@
"</span>"
: "") +
'<span class="svc-path">' + escHtml(s.host + "." + base) + "</span>";
if (s.ok && url) {
// 「打开」优先用**路径形态**(url_portal):
// - 它没有 DNS 依赖,单端口穿透 / 子域无证书时都能用;
// - 子域形态要求 DNS 能解析 <host>.<基域名>,而这些域名
// 在外部常常不可达(实测:外层只放行一个 Host,三级子域
// 因通配证书不匹配而握手失败)。
// 保留子域链接作为次选(局域网内直连时它更直观)。
var primary = s.url_portal || url;
if (s.ok && primary) {
html +=
'<a class="btn btn-primary btn-sm" style="margin-left:auto" target="_blank" rel="noopener" href="' +
escHtml(url) +
escHtml(primary) +
'">' +
__("打开", "Open") +
"</a>";
if (url && s.url_portal && url !== s.url_portal) {
html +=
'<a class="btn btn-ghost btn-sm" target="_blank" rel="noopener" title="' +
escHtml(url) +
'" href="' +
escHtml(url) +
'">' +
__("子域", "subdomain") +
"</a>";
}
} else {
html +=
'<span class="svc-err" title="' +