chore: 并入已删除会话的在途成果(防丢失;原会话已删,未做功能验收)
**背景**:这些改动原属本工作区另外几个会话(T01/T02 等),**那些会话已被用户删除** ⇒
工作树里的成果处于"无主"状态,一次错误 checkout / 覆盖即**永久丢失** ⇒ 代入库保全。
口径遵循本项目**先例**(`39a1f2e` / `b617cdb`:**别人的活,代入库并在提交信息里注明**)。
**内容**:档案 101「能力管理」改名 + 页内 tab 分页|档案 102 语言切换搬入「用户设置」|
`07-实例UI分区登记表.md`|`scripts/find-ui*.mjs`(UI 元素定位工具)|`poc/portal-entry/`(0.5.3)|
`src/web/locale-pref.ts` + `home-files.ts`(语言偏好持久化)|`test/locale-pref.test.mjs`|
`package.json`|`BRIEF.md` / `INDEX.md` / `docs-manifest.json` / `03-路线图与待办.md` / 档案 100 增量。
**已做最小健全性检查**(⚠️ **未跑完整构建 / 单测** —— 那是原会话的验收职责,本次只求"不丢"):
- JSON 合法:`package.json` / `poc/business-plugins/package.json` / `docs-manifest.json` ✓
- 4 个 TS 文件 `{}`/`()` 配平 ✓;新增文件均非空 ✓
- 规模:13 文件改动 +340/−210,新增 12 条
**未 push**(按 §4 提交边界:用户说"提交",未说"推送")。
This commit is contained in:
1 parent
03c8363960
commit
3efd68517f
27 files changed
+1712
-206
No files matched your search
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""compare-tree.py —— 把**本地 git 基线**(manifest:mode/type/sha\\tpath)与服务器工作树逐文件比对。
|
||||
|
||||
输出三类(这是"能不能整树覆盖"的唯一判据,不盲覆盖):
|
||||
· 一致 :服务器文件内容 == 本地基线
|
||||
· 不一致 :两边都有但内容不同(**要逐个看是谁的改动**)
|
||||
· 服务器独有:服务器有、基线里没有(**绝不能抹**)
|
||||
再加一类「仅在基线里」:本地有、服务器缺(同步时要补上)。
|
||||
|
||||
用法(在服务器上):python3 scripts/compare-tree.py <manifest> <工作树根>
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
manifest, root = sys.argv[1], sys.argv[2]
|
||||
entries = []
|
||||
for line in open(manifest, encoding='utf-8'):
|
||||
line = line.rstrip('\n')
|
||||
if not line.strip():
|
||||
continue
|
||||
head, path = line.split('\t', 1)
|
||||
mode, typ, sha = head.split()
|
||||
entries.append((path, sha))
|
||||
|
||||
paths = [p for p, _ in entries]
|
||||
present = [p for p in paths if os.path.isfile(os.path.join(root, p))]
|
||||
missing = [p for p in paths if p not in set(present)]
|
||||
|
||||
# 批量算 sha(git hash-object --stdin-paths)
|
||||
hash_out = {}
|
||||
if present:
|
||||
proc = subprocess.run(['git', 'hash-object', '--stdin-paths'], cwd=root,
|
||||
input='\n'.join(present) + '\n', capture_output=True, text=True)
|
||||
for p, h in zip(present, proc.stdout.split('\n')):
|
||||
if h.strip():
|
||||
hash_out[p] = h.strip()
|
||||
|
||||
same, diff = [], []
|
||||
for p, sha in entries:
|
||||
if p in missing:
|
||||
continue
|
||||
if hash_out.get(p) == sha:
|
||||
same.append(p)
|
||||
else:
|
||||
diff.append((p, sha, hash_out.get(p)))
|
||||
|
||||
# 服务器独有:扫同一批顶层目录,取基线里没有的文件
|
||||
tops = sorted({p.split('/')[0] for p, _ in entries})
|
||||
known = set(paths)
|
||||
extra = []
|
||||
for top in tops:
|
||||
base = os.path.join(root, top)
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(base):
|
||||
dirnames[:] = [d for d in dirnames if d not in ('node_modules', '.git', 'dist')]
|
||||
for fn in filenames:
|
||||
rel = os.path.relpath(os.path.join(dirpath, fn), root).replace(os.sep, '/')
|
||||
if rel not in known:
|
||||
extra.append(rel)
|
||||
|
||||
print('=== 汇总 ===')
|
||||
print(' 基线条目 : %d' % len(entries))
|
||||
print(' 一致 : %d' % len(same))
|
||||
print(' 不一致 : %d' % len(diff))
|
||||
print(' 仅基线有(缺) : %d' % len(missing))
|
||||
print(' 服务器独有 : %d' % len(extra))
|
||||
|
||||
if diff:
|
||||
print('\n=== 不一致(要逐个判断是谁的改动)===')
|
||||
for p, want, got in diff[:60]:
|
||||
print(' %-58s 基线 %s 服务器 %s' % (p, want[:10], (got or '')[:10]))
|
||||
if len(diff) > 60:
|
||||
print(' … 还有 %d 个' % (len(diff) - 60))
|
||||
|
||||
if missing:
|
||||
print('\n=== 仅基线有(服务器缺)===')
|
||||
for p in missing[:40]:
|
||||
print(' ' + p)
|
||||
if len(missing) > 40:
|
||||
print(' … 还有 %d 个' % (len(missing) - 40))
|
||||
|
||||
if extra:
|
||||
print('\n=== 服务器独有(⛔ 覆盖前必须保留/处理)===')
|
||||
for p in extra[:60]:
|
||||
print(' ' + p)
|
||||
if len(extra) > 60:
|
||||
print(' … 还有 %d 个' % (len(extra) - 60))
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""find-ui-scan.py —— 在**服务器上**扫描「哪些包注册了实例 UI 分区」,输出 TSV。
|
||||
|
||||
由 `scripts/find-ui.cjs` 通过 `ssh <host> python3 -` 喂进来执行(本文件不在服务器上落地)。
|
||||
为什么要落到服务器上跑:**权威事实是"活着的 profile 里装了什么"**,不是仓里的快照、
|
||||
也不是文档库里的历史副本(2026-09-15 就是在这上面绕了很久)。
|
||||
|
||||
输出 TSV(每行一个分区):
|
||||
pkg <TAB> origin <TAB> file <TAB> id <TAB> order <TAB> label_raw
|
||||
|
||||
scan 顺序:**自研(@dsh-local)在前**,官方在后 —— 排查时先看自己的。
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
PROFILE_GLOB = '/var/lib/dshs/users/*/home/profiles/web/node_modules/@dsh-local/*/lib/client.js'
|
||||
OFFICIAL_GLOB = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/*/lib/client.js'
|
||||
|
||||
# 注册对象形如: { name: "settings.section", id: "x", order: 100, label: … }
|
||||
REG = re.compile(r'name:\s*"settings\.section"')
|
||||
ID = re.compile(r'\bid:\s*"([^"]+)"')
|
||||
ORDER = re.compile(r'\border:\s*(-?\d+)')
|
||||
LABEL_LIT = re.compile(r'label:\s*(?:\([^)]*\)\s*=>\s*|function\s*\([^)]*\)\s*\{\s*return\s*)\s*"((?:[^"\\]|\\.)*)"')
|
||||
LABEL_KEY = re.compile(r'label:\s*(?:\([^)]*\)\s*=>\s*|function\s*\([^)]*\)\s*\{\s*return\s*)\s*t\(\s*"([^"]+)"\s*\)')
|
||||
|
||||
|
||||
def unique_files(pattern):
|
||||
"""同一 profile 内容一致 ⇒ 只取**第一个** profile,避免同一分区重复几十行。"""
|
||||
hits = sorted(glob.glob(pattern))
|
||||
if not hits:
|
||||
return []
|
||||
if 'users' in pattern:
|
||||
# 取第一个用户目录下的全部 @dsh-local 包
|
||||
first_user = hits[0].split('/home/profiles/')[0]
|
||||
return [h for h in hits if h.startswith(first_user + '/')]
|
||||
return hits
|
||||
|
||||
|
||||
def label_of(text, raw):
|
||||
"""label 是字面量 ⇒ 解码转义;是 t("key") ⇒ 在同文件的词典里找值。"""
|
||||
m = LABEL_LIT.search(raw)
|
||||
if m:
|
||||
lit = m.group(1)
|
||||
# 关键:`\u7528\u6237\u8bbe\u7f6e` 这类**转义 unicode** 要还原成人能看的字
|
||||
try:
|
||||
return json.loads('"' + lit + '"')
|
||||
except Exception:
|
||||
return lit
|
||||
m = LABEL_KEY.search(raw)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
d = re.search(r'"' + re.escape(key) + r'":\s*"((?:[^"\\]|\\.)*)"', text)
|
||||
if d:
|
||||
try:
|
||||
return json.loads('"' + d.group(1) + '"')
|
||||
except Exception:
|
||||
return d.group(1)
|
||||
return 't("%s")' % key
|
||||
return '(无 label)'
|
||||
|
||||
|
||||
def scan(files, origin):
|
||||
for f in files:
|
||||
try:
|
||||
text = open(f, encoding='utf-8', errors='replace').read()
|
||||
except Exception:
|
||||
continue
|
||||
if 'settings.section' not in text:
|
||||
continue
|
||||
# ⚠️ 用 removeprefix 而不是 lstrip:lstrip 是按**字符集**删,会把官方包的 "@deepseek" 也啃掉
|
||||
k = f.split('/node_modules/')[-1].split('/lib/client.js')[0]
|
||||
pkg = k.removeprefix('@dsh-local/')
|
||||
for m in REG.finditer(text):
|
||||
raw = text[m.start():m.start() + 420]
|
||||
i = ID.search(raw)
|
||||
if not i:
|
||||
continue
|
||||
o = ORDER.search(raw)
|
||||
print('\t'.join([
|
||||
pkg, origin, f, i.group(1), o.group(1) if o else '—', label_of(text, raw),
|
||||
]))
|
||||
|
||||
|
||||
def main():
|
||||
mine = unique_files(PROFILE_GLOB)
|
||||
if not mine:
|
||||
sys.stderr.write('!! 没找到任何 profile 的 @dsh-local 包(集群模式下用户可能落别的 Worker)\n')
|
||||
scan(mine, '自研')
|
||||
scan(unique_files(OFFICIAL_GLOB), '官方')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* find-ui.mjs —— 「**这个 UI 分区是谁提供的、源码在哪、我能不能改**」一条命令问清楚。
|
||||
*
|
||||
* ## 为什么有它(2026-09-15 实录)
|
||||
* 为搞清设置面板里的「用户设置」是谁渲染的,我在 6 个官方包 + 3 个自研包之间逐个 grep,
|
||||
* 绕了约 30 次工具调用。根因**不是**代码烂,而是三件事叠加:
|
||||
* ① 那个 label 用**转义 unicode** 存(`"\u7528\u6237\u8bbe\u7f6e"`)⇒ 按 UTF-8 grep 永远 0 命中;
|
||||
* ② `portal-entry` 的**源码不在仓里**(只有服务器上的 tgz)⇒ 只能从产物反推;
|
||||
* ③ **没有"哪个包提供哪个 UI"的索引**,id/order/label 散在各包里。
|
||||
* 本脚本一次性解决 ①③(② 的处置:把源码补进 `poc/<name>/`,见 `07-实例UI分区登记表.md`)。
|
||||
*
|
||||
* ## 用法
|
||||
* node scripts/find-ui.mjs # 列出全部实例 UI 分区(自研在前)
|
||||
* node scripts/find-ui.mjs 用户设置 # 按关键词定位(**UTF-8 与转义形态都搜**)
|
||||
* node scripts/find-ui.mjs locale --grep # 顺带打印命中行上下文
|
||||
* node scripts/find-ui.mjs --md # 输出 Markdown 表格行(用于刷新 `07-实例UI分区登记表.md`)
|
||||
* DSHS_SSH=other-host node scripts/find-ui.mjs # 指定 ssh 目标(默认 bt-server)
|
||||
*
|
||||
* ## 判据(为什么这么做才对)
|
||||
* · **权威事实 = 活着的 profile 里装了什么**,不是仓里快照、不是文档库历史副本、不是备份目录;
|
||||
* · 标签必须**两种形态都搜**(字面量 + `\uXXXX`),否则会像 2026-09-15 那样全域 0 命中;
|
||||
* · 「能不能改」= 包在 `@dsh-local/`(我们自己的,可改)还是在 `@deepseek-ai/`(官方,R2 不可改)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const SSH = process.env.DSHS_SSH ?? 'bt-server'
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const asMd = argv.includes('--md')
|
||||
const withGrep = argv.includes('--grep')
|
||||
const keyword = argv.find((a) => !a.startsWith('--')) ?? ''
|
||||
|
||||
// ── 1. 把扫描器喂给服务器执行(脚本不落地在服务器上)──
|
||||
const scanner = readFileSync(join(ROOT, 'scripts', 'find-ui-scan.py'), 'utf8')
|
||||
const r = spawnSync(
|
||||
'ssh',
|
||||
['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'StrictHostKeyChecking=accept-new', SSH, 'python3 -'],
|
||||
{ input: scanner, encoding: 'utf8', maxBuffer: 1 << 24 },
|
||||
)
|
||||
if (r.status !== 0 && (r.stdout ?? '') === '') {
|
||||
console.error('!! 远程扫描失败:' + (r.stderr || '').split('\n').filter((l) => !/post-quantum|store now|openssh/.test(l)).join('\n').slice(0, 600))
|
||||
process.exit(1)
|
||||
}
|
||||
const rows = (r.stdout ?? '')
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('\t'))
|
||||
.map((l) => {
|
||||
const [pkg, origin, file, id, order, label] = l.split('\t')
|
||||
return { pkg, origin, file, id, order, label }
|
||||
})
|
||||
|
||||
// ── 2. 关键词过滤:**字面量 + 转义形态都搜**(本脚本存在的理由之一)──
|
||||
const escapeOf = (s) =>
|
||||
Array.from(s).map((c) => (c.codePointAt(0) > 127 ? '\\u' + c.codePointAt(0).toString(16).padStart(4, '0') : c)).join('')
|
||||
const kwEsc = escapeOf(keyword).toLowerCase()
|
||||
const hit = (v) => {
|
||||
const s = String(v ?? '').toLowerCase()
|
||||
return s.includes(keyword.toLowerCase()) || (kwEsc !== keyword.toLowerCase() && s.includes(kwEsc))
|
||||
}
|
||||
const shown = keyword === '' ? rows : rows.filter((x) => hit(x.id) || hit(x.label) || hit(x.pkg))
|
||||
|
||||
// ── 3. 输出 ──
|
||||
if (asMd) {
|
||||
console.log('| order | id | label | 提供者 | 来源 |')
|
||||
console.log('|---|---|---|---|---|')
|
||||
for (const x of shown) console.log(`| ${x.order} | \`${x.id}\` | ${x.label} | \`${x.pkg}\` | ${x.origin} |`)
|
||||
} else if (shown.length === 0) {
|
||||
console.log(`没有匹配「${keyword}」的分区。共扫到 ${rows.length} 个分区:`)
|
||||
for (const x of rows) console.log(` · ${x.order.toString().padStart(4)} ${x.id} [${x.label}] ${x.pkg} (${x.origin})`)
|
||||
console.log('\n提示:关键词会同时按 UTF-8 与 \\uXXXX 转义搜;仍找不到 ⇒ 该文案可能不在「分区」层,去 06/07 看。')
|
||||
} else {
|
||||
console.log(`命中 ${shown.length} / 共 ${rows.length} 个实例 UI 分区${keyword ? `(关键词「${keyword}」)` : ''}:\n`)
|
||||
for (const x of shown) {
|
||||
console.log(`● ${x.label} [id=${x.id} order=${x.order}]`)
|
||||
console.log(` 提供者:${x.pkg} (${x.origin} ⇒ ${x.origin === '自研' ? '✅ 可改' : '⛔ 官方,R2 不可改'})`)
|
||||
console.log(` 源码 :${x.file}`)
|
||||
if (withGrep) {
|
||||
const g = spawnSync('ssh', ['-o', 'BatchMode=yes', SSH, `grep -n "settings.section" -A 6 "${x.file}" | head -12`], { encoding: 'utf8' })
|
||||
console.log((g.stdout ?? '').split('\n').map((l) => ' | ' + l).join('\n'))
|
||||
}
|
||||
}
|
||||
const mine = shown.filter((x) => x.origin === '自研')
|
||||
if (mine.length > 0) {
|
||||
console.log('\n下一步:')
|
||||
console.log(' · 自研包 ⇒ **源码应在仓内 `poc/<pkg>/`**;不在 ⇒ 先把部署中的版本取出来入仓(约定:产物只从仓内源码构建)。')
|
||||
console.log(' · 改完跑 `npm run verify`(含 verify-my-skills / verify-portal-entry 等结构断言),再走投放。')
|
||||
console.log(' · ⛔ 改分区 ⇒ **同一次改动里刷新 `dsh-server-docs/07-实例UI分区登记表.md`**(可用 `--md` 生成表行)。')
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,34 @@ ok('prefers-reduced-motion 覆盖了 .bp-skillRow', /prefers-reduced-motion[^}]*
|
||||
ok('en 的 msk.hint 写明 no instance restart', all.length === 2 && /no instance restart/i.test(all[1]))
|
||||
}
|
||||
|
||||
// ── ⑧ 分区改名 + 页内 tab 分页 + 卡片三行(v0.3.22 · 2026-09-15 用户三项要求)──
|
||||
// ① 「功能管理」→「能力管理」② 按 tab 切插件/技能各自操作 ③ 插件卡片中文用途描述 3 行
|
||||
ok('zh 分区名已改为「能力管理」', /"section\.label": "能力管理"/.test(src))
|
||||
ok('en 分区名已改为 Capability management', /"section\.label": "Capability management"/.test(src))
|
||||
ok('旧名「功能管理」不再是任何 label', /"section\.label": "功能管理"/.test(src) === false)
|
||||
ok('tab 标签词条存在(cap.tabPlugins)', /"cap\.tabPlugins": "功能插件"/.test(src) && /"cap\.tabPlugins": "Feature plugins"/.test(src))
|
||||
|
||||
ok('顶部有 tab 条(复用 .pa-tabs + role=tablist)', /className: "pa-tabs",\s*\n\s*role: "tablist"/.test(src))
|
||||
ok('两个页签(插件 / 技能)都渲染', /t\("cap\.tabPlugins"\)/.test(src) && /t\("msk\.group"\)/.test(src))
|
||||
ok('页签带计数(.pa-tcnt)', /className: "pa-tcnt"/.test(src))
|
||||
ok('页签有 aria-selected(可访问性)', /"aria-selected": isPlugins \? "true" : "false"/.test(src))
|
||||
ok('两个页签各自可切换', /setTab\("plugins"\)/.test(src) && /setTab\("skills"\)/.test(src))
|
||||
ok('插件块被 isPlugins 门控', /if \(isPlugins\) \{/.test(src))
|
||||
ok('技能块被 !isPlugins 门控', /if \(!isPlugins\) \{/.test(src))
|
||||
{
|
||||
// 三个区段(插件主体 / 技能主体 / 插件专属弹窗与提示 / 技能专属弹窗)都要有开有闭
|
||||
const opens = (src.match(/if \(isPlugins\) \{/g) || []).length
|
||||
const notOpens = (src.match(/if \(!isPlugins\) \{/g) || []).length
|
||||
ok('门控数量成对(插件 2 处 / 技能 2 处)', opens === 2 && notOpens === 2, `-> isPlugins ${opens} / !isPlugins ${notOpens}`)
|
||||
}
|
||||
|
||||
ok('插件卡片用途描述用 .bp-plugDesc', /className: "bp-plugDesc"/.test(src))
|
||||
ok('.bp-plugDesc 有 -webkit-line-clamp:3', cssHas('bp-plugDesc', '-webkit-line-clamp:3'))
|
||||
ok('.bp-plugDesc 有显式 white-space:normal(否则继承单行截断)', cssHas('bp-plugDesc', 'white-space:normal'))
|
||||
ok('.bp-plugDesc 有 overflow:hidden', cssHas('bp-plugDesc', 'overflow:hidden'))
|
||||
ok('卡片描述仍挂 title(hover 看全文)', /className: "bp-plugDesc",[\s\S]{0,200}title: primary/.test(src))
|
||||
ok('.bp-tabHead 存在(技能页工具行:左说明 / 右上传)', cssHas('bp-tabHead', 'justify-content:space-between'))
|
||||
|
||||
// ── ⑦ 不得引用官方包/不得写 exports.default(R2 / R3 的机械兜底)──
|
||||
// ⚠️ 只断言**赋值形态**:本文件头部注释里就写着「绝不 exports.default」这句话,
|
||||
// 用 `includes()` 会被自己的注释判红(首跑实测踩到)。
|
||||
|
||||
@@ -183,9 +183,10 @@ function clsAll(tree) {
|
||||
}
|
||||
|
||||
// ── 注册与门禁 ────────────────────────────────────────────────────────────────
|
||||
// 档案 87 起 = 3 个:模型设置(全角色)+ 功能管理(全角色)+ 系统管理(仅 admin)
|
||||
// 档案 87 起 = 3 个:模型设置(全角色)+ 能力管理(全角色,原「功能管理」)+ 系统管理(仅 admin)
|
||||
// 档案 81 · R5(2026-09-15)新增「偏好设置」= 语言切换(全角色,order 99)⇒ 共 **4** 个
|
||||
ok("admin 视角下注册了四个 settings.section", regs.length === 4, "-> " + regs.map(r => r.meta.id).join(", "));
|
||||
// 2026-09-15:「偏好设置」撤除 ⇒ admin 侧 4 → 3(模型设置 + 能力管理 + 系统管理)
|
||||
ok("admin 视角下注册了三个 settings.section", regs.length === 3, "-> " + regs.map(r => r.meta.id).join(", "));
|
||||
const pa = regs.find(r => r.meta.id === "platform-admin");
|
||||
ok("存在 platform-admin 分区", !!pa);
|
||||
if (pa) ok("其 order = 102", pa.meta.order === 102);
|
||||
@@ -213,30 +214,24 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
|
||||
mod.apply(ctx);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const ids = regs.map(r => r.meta.id).sort();
|
||||
ok("非 admin:注册 3 个分区(偏好设置 + 功能管理 + 模型设置;不含系统管理)",
|
||||
ids.length === 3 && ids[0] === "business-plugins" && ids[1] === "model-settings" && ids[2] === "preferences",
|
||||
// 2026-09-15:「偏好设置」分区已撤除(语言切换搬到 portal-entry 的「用户设置」)⇒ 3 → 2
|
||||
ok("非 admin:注册 2 个分区(能力管理 + 模型设置;不含系统管理,也不再有偏好设置)",
|
||||
ids.length === 2 && ids[0] === "business-plugins" && ids[1] === "model-settings",
|
||||
"-> " + ids.join(", "));
|
||||
regs = []; ROLE = "admin";
|
||||
mod.apply(ctx);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
|
||||
// ── 档案 81 · R5(2026-09-15):「偏好设置」= 语言切换(全角色,走官方 locale)──────
|
||||
// 口径:默认英语(官方 FALLBACK_LOCALE="en");切换走 ctx.locale.setLocale(id),
|
||||
// 语言 id 必须与官方 LOCALE_IDS 一致(en / zh)。本断言同时充当「该栏真能渲染」的冒烟。
|
||||
// ── 2026-09-15(用户要求):**「偏好设置」分区已撤除** ────────────────────────────
|
||||
// 语言切换搬到 `@dsh-local/portal-entry` 的「用户设置」分区(该 bundle 的断言见
|
||||
// `scripts/verify-portal-entry.mjs`)。这里只断言"它真的没了"—— 防止将来又把重复入口加回来。
|
||||
{
|
||||
regs = []; ROLE = "active";
|
||||
mod.apply(ctx);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const pref = regs.find(r => r.meta.id === "preferences");
|
||||
ok("存在 preferences 分区(语言切换)", !!pref);
|
||||
if (pref) {
|
||||
ok("其 order = 99(排在模型设置之前)", pref.meta.order === 99, "-> " + pref.meta.order);
|
||||
const tPref = text(await render(pref.Comp)).join(" | ");
|
||||
ok("偏好设置:含「界面语言」标签 + 英文/中文两个语言项",
|
||||
tPref.includes("界面语言") && tPref.includes("English") && tPref.includes("中文"),
|
||||
"-> " + tPref.slice(0, 140));
|
||||
}
|
||||
ok("preferences 分区已撤除(不再注册)", regs.every(r => r.meta.id !== "preferences"),
|
||||
"-> " + regs.map(r => r.meta.id).join(", "));
|
||||
regs = []; ROLE = "admin";
|
||||
mod.apply(ctx);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
@@ -247,15 +242,18 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
|
||||
const ms = regs.find(r => r.meta.id === "model-settings");
|
||||
ok("存在 model-settings 分区", !!ms);
|
||||
if (ms) {
|
||||
ok("其 order = 100(排在功能管理之前)", ms.meta.order === 100, "-> " + ms.meta.order);
|
||||
ok("其 order = 100(排在能力管理之前)", ms.meta.order === 100, "-> " + ms.meta.order);
|
||||
const tMs = text(await render(ms.Comp)).join(" | ");
|
||||
ok("模型设置:标题与副标题", tMs.includes("模型设置") && tMs.includes("模型选择器"));
|
||||
// 口径②:共享模型也列入且可开关
|
||||
ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型"));
|
||||
// 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」
|
||||
ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用"));
|
||||
ok("模型设置:内置条目显示为「内置 DeepSeek」、自定义条目显示其 route",
|
||||
tMs.includes("内置 DeepSeek") && tMs.includes("my-gw"));
|
||||
// 2026-09-15 用户口径:「内置 DeepSeek」**去掉「内置」**,就叫「DeepSeek」。
|
||||
// 顺带断言那个误导性的「(平台共享)」后缀不再出现 —— 该选项走内置通道,但仍**要填
|
||||
// 用户自己的 API 密钥**,写成「平台共享」会让人以为不必填 key。
|
||||
ok("模型设置:条目名显示为「DeepSeek」(不再有「内置 DeepSeek」)",
|
||||
tMs.includes("DeepSeek") && !tMs.includes("内置 DeepSeek") && tMs.includes("my-gw"));
|
||||
ok("模型设置:自定义条目副行显示 endpoint · 协议",
|
||||
tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions"));
|
||||
// ── 档案 91(2026-09-14):「新增」改官方**两步式** ──────────────────────────
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-portal-entry.mjs —— `@dsh-local/portal-entry` 客户端面的**结构防线**(档案 102)
|
||||
*
|
||||
* 为什么需要(三条都真踩过):
|
||||
* ① **v0.5.1 事故**:打出来的 tgz **漏了 `lib/index.js`(host 半边)** ⇒ 每个实例
|
||||
* `ERR_MODULE_NOT_FOUND` 起不来。⇒ 本脚本把"两个半边文件都在"钉成断言。
|
||||
* ② **v0.4.1 事故 / R3**:`exports.default = apply` 会让 loader 选中裸函数当插件体 ⇒
|
||||
* `ctx.slots` 抛「cannot get property slots without inject」⇒ 分区**永不渲染**(静默)。
|
||||
* ③ **2026-09-15 变更**:「界面语言」切换搬进本区(原在 business-plugins 的「偏好设置」分区,
|
||||
* 该分区已撤)⇒ 需要钉住"语言行确实在、且真的调官方 locale API"。
|
||||
*
|
||||
* ⚠️ **label 存的是转义 unicode**(`"\u7528\u6237\u8bbe\u7f6e"` = 用户设置)—— 这是本 bundle 的
|
||||
* 既有写法,本脚本按**转义形态**断言(按 UTF-8 字面量 grep 会永远搜不到,2026-09-15 我为此
|
||||
* 绕了很久)。
|
||||
*
|
||||
* 用法:node scripts/verify-portal-entry.mjs 退出码 0=全绿 / 1=有失败
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const P = 'poc/portal-entry'
|
||||
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
|
||||
|
||||
let failed = 0
|
||||
const ok = (name, cond, extra = '') => {
|
||||
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
||||
if (!cond) failed++
|
||||
}
|
||||
|
||||
// ── ① 源码必须在仓里(2026-09-15 之前不在 ⇒ 只能从 tgz 反推源码,排查成本极高)──
|
||||
ok('源码在仓内(poc/portal-entry/lib/client.js)', existsSync(join(ROOT, P, 'lib', 'client.js')))
|
||||
ok('host 半边也在(lib/index.js —— v0.5.1 事故的防线)', existsSync(join(ROOT, P, 'lib', 'index.js')))
|
||||
ok('package.json 在', existsSync(join(ROOT, P, 'package.json')))
|
||||
ok('cordis.patch.yml 在(bundle patch 必需)', existsSync(join(ROOT, P, 'cordis.patch.yml')))
|
||||
|
||||
const src = read(P + '/lib/client.js')
|
||||
const pkg = JSON.parse(read(P + '/package.json'))
|
||||
|
||||
// ── ② 版本与元数据 ──
|
||||
// ⚠️ 0.5.4 事故:目录里上一版的 `.tgz` 被一并打进产物(package/<name>-0.5.3.tgz)—— 与
|
||||
// business-plugins 当年 0.2.5 完全同一个坑。防线:`.npmignore` 必须在且排除 `*.tgz`。
|
||||
ok('.npmignore 存在', existsSync(join(ROOT, P, '.npmignore')))
|
||||
ok('.npmignore 排除 *.tgz(防"旧产物打进新包"复发)',
|
||||
existsSync(join(ROOT, P, '.npmignore')) && read(P + '/.npmignore').includes('*.tgz'))
|
||||
|
||||
ok('version ≥ 0.5.3(语言行引入版)', /^0\.5\.[3-9]$/.test(pkg.version) || /^0\.[6-9]/.test(pkg.version), '-> ' + pkg.version)
|
||||
ok('client bundle 元数据在(dsh.client.platform = web)', pkg.dsh && pkg.dsh.client && pkg.dsh.client.platform === 'web')
|
||||
|
||||
// ── ③ 分区:id user-settings / order 103 / label = 用户设置(转义形态)──
|
||||
ok('注册 settings.section', src.includes('settings.section'))
|
||||
ok('section id = user-settings', src.includes('id: "user-settings"'))
|
||||
ok('order = 103', /order:\s*103/.test(src))
|
||||
ok('label = 用户设置(转义 unicode \\u7528\\u6237\\u8bbe\\u7f6e)', src.includes('\\u7528\\u6237\\u8bbe\\u7f6e'))
|
||||
|
||||
// ── ④ 语言切换(2026-09-15 搬入)──
|
||||
ok('inject 里要了 locale 服务', /const inject = \["slots", "locale"\]/.test(src))
|
||||
ok('读当前语言走官方 ctx.locale.getLocale()', /hostCtx\.locale\.getLocale\(\)/.test(src))
|
||||
ok('切语言走官方 ctx.locale.setLocale()', /hostCtx\.locale\.setLocale\(id\)/.test(src))
|
||||
ok('注册了自己的双语词典(ctx.locale.register)', /locale\.register\(PE_NS, PE_DICT\)/.test(src))
|
||||
ok('语言项 en / zh 都在', src.includes('{ id: "en"') && src.includes('{ id: "zh"'))
|
||||
ok('渲染了 select(界面语言)', src.includes('"aria-label": tPe("lang")'))
|
||||
ok('切换后强制重渲染(setTick)', /setTick\(function \(n\)/.test(src))
|
||||
{
|
||||
// 语言行必须真在 rows 里(否则写了不渲染 = 用户看不到)。
|
||||
// ⚠️ 断言方向:代码是 `rows.push(jsxRuntime.jsxs("div", { key: "lang", …` ⇒
|
||||
// `rows.push` 在 `key: "lang"` **之前**(首跑按"之后"判,误红一次)。
|
||||
const at = src.indexOf('key: "lang"')
|
||||
ok('语言行确实 push 进 rows', at > 0 && src.slice(Math.max(0, at - 400), at).includes('rows.push'), '-> offset ' + at)
|
||||
}
|
||||
// ── ④b 语言偏好持久化(v0.5.4):调平台把选择写进 settings.yaml ──
|
||||
// 官方只对 loopback 持久化 ⇒ 平台侧补一刀;断言"确实调了",防止将来被误删成"切了记不住"。
|
||||
ok('切换时调平台持久化(POST /api/me/locale)', /\/api\/me\/locale/.test(src))
|
||||
ok('持久化请求带 locale 字段', /JSON\.stringify\(\{ locale: id \}\)/.test(src))
|
||||
ok('持久化用 credentials: "include"(跨子域带会话)', /credentials: "include"/.test(src))
|
||||
ok('持久化失败不影响本次切换(catch 兜底)', /\.catch\(function \(e\) \{ console\.warn\("\[portal-entry\] 语言偏好持久化失败/.test(src))
|
||||
|
||||
ok('颜色硬编码(本区在 DARK 主题,v0.4.3 结论:不可用 var(--dsw-*))',
|
||||
src.includes('#f9fafb') && src.includes('#43464d') && !/var\(--dsw-alias-label-primary/.test(src))
|
||||
|
||||
// ── ⑤ R3:不得出现 exports.default 赋值(v0.4.1 事故的根因)──
|
||||
ok('未出现 exports.default 赋值(R3)', /(?:^|\n)\s*(?:module\.)?exports\.default\s*=/.test(src) === false)
|
||||
ok('导出 apply + inject', /exports\.apply = apply/.test(src) && /exports\.inject = inject/.test(src))
|
||||
|
||||
console.log(failed === 0 ? '\n全部通过(portal-entry:结构 + 语言行 + R3 防线)' : `\n${failed} 项失败`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
Reference in new issue
Block a user