Files
liuzhuohua d2f4caee9c 新增电光蓝/藏青色主题预设,中文化网络异常提示,新增 API 对齐检查器
1. 主题(backend/theme.py)
   - make_palette 新增 deep 参数:深色主题可指定独立底色,
     解决多个 dark 主题共用 #111827 底色、换主色后观感雷同的问题
   - 深色分支的四档状态浅底改用「提亮 + 向各自状态色偏」双通道派生,
     修掉 ok_l / err_l 撞成同色(navy 上四档不可区分)的问题
   - 新增 2 个内置预设:electric-blue(电光蓝,浅色,高饱和青蓝)、
     navy(藏青色,深色,deep=#0d1526,夜间低眩光)。内置主题 6 → 8
   - 对比度实测全部 ≥ WCAG AA:电光蓝最低 4.67,藏青最低 4.70

2. 中文化(web/app.js)
   - 新增 netErrCn():把浏览器原生英文异常(Failed to fetch /
     NetworkError / 证书错误 / 权限拒绝 / 解析失败等)归类为 8 种
     可读中文原因,原始串仅保留在 detail 供排查
   - api() 的网络失败与 JSON 解析失败分支改为中文主提示
   - 替换 7 处 e.message 直出(下载×3、文件读取、加载、初始化、导出)
   - 页面渲染 .catch 统一为中文主提示 + 小字技术细节

3. 测试
   - 新增 _dev/t-api106.py:API 五维对齐检查
     (前端路径→后端路由 / 方法语义 / 查询参数名 / PAGE_MODULES 落点 /
      后端未被调用路由),5 项全通过,前端 88 个静态路径全部可路由
   - t-theme.py:内置主题数断言改为非空(避免加主题误红),
     新增四档状态浅底互不相同 + 两个新预设共 30 项断言,831 项全绿
2026-10-02 22:31:16 +08:00

219 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""API 对齐深度检查器(1.0.6 补)。
比 t-apicheck.py 更进一步,盯三件之前没系统查过的事:
A. **前端调用的路径后端真的能路由到吗** —— 用 AST 抽出 web 层所有
`p == '...'` / 元组多路径 / `p.startswith('...')` 分发,逐一比对。
查「前端在调、后端没有」= 404 型 bug。
B. **后端有、前端从不调** —— 不是 bug,但可能意味着「功能没接上」
或「死代码」,列出来人工判断。
C. **HTTP 方法用得对吗** —— 有写操作(save/delete/install/apply)
却用 GET 的,属于把幂等语义搞坏;反之纯读接口用 POST 只是浪费。
刻意不查字段级(t-apicheck.py 已经查了字段转发与分支可达性),
这里只管「路径 / 方法 / 覆盖」三个维度,避免与既有检查重复。
"""
import ast
import io
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def read(rel):
with io.open(os.path.join(ROOT, rel), encoding='utf-8') as f:
return f.read()
WEB = read('backend/drouter-web.py')
APP = read('web/app.js')
HELPER = read('backend/drouter-helper.py')
PASS = 0
FAIL = 0
MSG = []
def chk(desc, cond, extra=''):
global PASS, FAIL
if cond:
PASS += 1
print('[OK] %s' % desc)
else:
FAIL += 1
print('[NG] %s' % desc)
if extra:
print(' %s' % (extra,))
# ------------------------------------------------------------------
# 一、从前端抽 API 调用
# ------------------------------------------------------------------
# api('/api/xxx', {method:'POST', body:{...}})
# api(`/api/xxx?a=${...}`) —— 模板串
FE = {} # 路径(去 query) -> [(method, lineno)]
for m in re.finditer(r"""\bapi\(\s*(['"`])([^'"`]+)\1\s*(,\s*\{)?""", APP):
raw = m.group(2)
# 拼出 method:往后看 260 字符内的 method:'XXX'
tail = APP[m.end():m.end() + 260]
mm = re.search(r"""method\s*:\s*['"](\w+)['"]""", tail)
method = (mm.group(1) if mm else 'GET').upper()
path = raw.split('?')[0].strip()
line = APP[:m.start()].count('\n') + 1
FE.setdefault(path, []).append((method, line))
# 变量拼接的路径(如 api(BASE + '/api/x'))单独记,这类没法静态判定
DYN = len(re.findall(r"""\bapi\(\s*[A-Za-z_$][\w.$]*\s*[+]""", APP))
print('--- A. 前端调用 → 后端能否路由 ---')
print('前端静态路径 %d 个(另有 %d 处变量拼接路径无法静态判定)'
% (len(FE), DYN))
# ------------------------------------------------------------------
# 二、从后端抽能路由到的路径
# ------------------------------------------------------------------
tree = ast.parse(WEB)
BE_EXACT = set() # 精确匹配
BE_PREFIX = [] # (前缀, 说明)
BE_TUPLE = set() # 元组里的路径
for node in ast.walk(tree):
# p == '/api/x' / p in ('/api/x', '/api/y')
if isinstance(node, ast.Compare):
left = node.left
if not (isinstance(left, ast.Name) and left.id == 'p'):
continue
for op, cmp_ in zip(node.ops, node.comparators):
if isinstance(op, (ast.Eq, ast.In)):
if isinstance(cmp_, ast.Constant) and isinstance(cmp_.value, str):
if cmp_.value.startswith('/api'):
BE_EXACT.add(cmp_.value)
elif isinstance(cmp_, (ast.Tuple, ast.List, ast.Set)):
for e in cmp_.elts:
if isinstance(e, ast.Constant) and isinstance(e.value, str) \
and e.value.startswith('/api'):
BE_TUPLE.add(e.value)
# p.startswith('/api/x')
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \
and node.func.attr == 'startswith' and node.args:
a0 = node.args[0]
if isinstance(a0, ast.Constant) and isinstance(a0.value, str) \
and a0.value.startswith('/api'):
BE_PREFIX.append(a0.value)
ROUTES = BE_EXACT | BE_TUPLE
PREFIXES = sorted(set(BE_PREFIX))
def routable(path):
if path in ROUTES:
return True, '精确路由'
for pre in PREFIXES:
if path == pre or path.startswith(pre):
return True, '前缀分发 %s' % pre
return False, ''
missing = []
for path in sorted(FE):
ok, how = routable(path)
if not ok:
missing.append(path)
chk('前端调用的每个路径后端都能路由到', not missing,
'后端无路由: %s' % ', '.join(missing))
print(' 后端可路由路径 %d 个(精确 %d / 元组 %d / 前缀 %d)'
% (len(ROUTES) + len(PREFIXES), len(BE_EXACT), len(BE_TUPLE), len(PREFIXES)))
# ------------------------------------------------------------------
print()
print('--- B. 方法语义:写操作不许用 GET ---')
# 只对「路径名本身表意是写操作」的接口断言,避免误伤查询。
# 关键词表要带尾边界:否则 /api/openapi(读文档)、/api/upgrade/info(查升级)
# 会被 'open' / 里的 'open' 命中,报出「写接口用 GET」的假红灯 ——
# 这两个都是纯读,后端也明确限定 method == 'GET'。
WRITE_HINT = re.compile(
r'/(save|delete|del|remove|install|apply|add|set|update|edit|write|mkdir|'
r'rename|zip|restore|rollback|reboot|restart|kill|stop|start|trigger|'
r'release|prune|cleanup|purge|import|run|exec|open|close|'
r'enable|disable|clear|reset|rebuild|regen|sync|flush|renew|revoke)'
r'(?![a-z])')
# 纯只读的路径名,无论命中哪个关键词都放过
READ_ONLY = re.compile(
r'/(openapi(\.json)?|upgrade/info|examples|sysinfo|metrics|health|me|'
r'config|status|list|info|logs?|audit|version|openapi)$')
GET_ON_WRITE = []
for path, calls in sorted(FE.items()):
if READ_ONLY.search(path) or not WRITE_HINT.search(path):
continue
for method, line in calls:
if method == 'GET':
GET_ON_WRITE.append('%s (行 %d)' % (path, line))
chk('写操作路径没有用 GET 调用', not GET_ON_WRITE,
'GET 调写接口: %s' % '; '.join(GET_ON_WRITE))
# 读接口用 POST 的(不算错,但列出来看规模)
READ_POST = []
for path, calls in sorted(FE.items()):
if READ_ONLY.search(path) or WRITE_HINT.search(path):
continue
for method, line in calls:
if method == 'POST':
READ_POST.append(path)
break
print(' 读接口走 POST 的 %d 个(允许,仅记录): %s'
% (len(READ_POST), ', '.join(READ_POST) if READ_POST else '无'))
# ------------------------------------------------------------------
print()
print('--- C. 查询串里的参数名是否与后端读取的一致 ---')
# 前端 api('/api/files/list?path=') → 后端 p.get('path')
QS = re.findall(r"""\bapi\(\s*['"`](/api/[^'"`?]*)\?([^'"`]*)['"`]""", APP)
qs_bad = []
for base, qs in QS:
keys = [k for k in re.findall(r'([a-zA-Z_][\w]*)\s*=', qs)]
for k in keys:
# 后端应能在该路径处理块里看到 p.get('k') 或 b.get('k')
if ("p.get('%s')" % k) not in WEB and ('"%s"' % k) not in WEB \
and ("'%s'" % k) not in WEB:
qs_bad.append('%s?%s=(后端未见该键)' % (base, k))
chk('查询串参数名后端都能读到', not qs_bad, '; '.join(qs_bad))
# ------------------------------------------------------------------
print()
print('--- D. 菜单页面与 API 覆盖:每个可渲染模块都有落点 ---')
# PAGE_MODULES 声明的模块必须有对应 API
m = re.search(r'const PAGE_MODULES\s*=\s*\{(.*?)\n\};', APP, re.S)
if not m:
chk('能解析 PAGE_MODULES', False, '未找到定义')
else:
body = m.group(1)
mods = re.findall(r'^\s*([a-z0-9_]+)\s*:', body, re.M)
chk('PAGE_MODULES 非空', bool(mods), str(len(mods)))
# 专属接口的模块不走 apply,这些是允许的
SPECIAL = set(re.findall(
r'^\s*([a-z0-9_]+)\s*:', body, re.M)) # 全部
# 只检查「声明了 apply 的模块」必须有 /api/apply 能力
applied = re.findall(r'([a-z0-9_]+)\s*:\s*\{[^}]*apply\s*:\s*\[[^\]]+\]', body)
print(' 可应用模块 %d 个: %s' % (len(applied), ', '.join(applied)))
chk('可应用模块都通过 /api/apply 落点', all(
p in APP for p in ('/api/apply',)), '前端应有 /api/apply 调用')
# ------------------------------------------------------------------
print()
print('--- E. 前端未调用的后端路由(供人工判断,不算失败)---')
unused = sorted(r for r in ROUTES if r not in FE and not r.startswith('/api/theme'))
for r in unused:
print(' · %s' % r)
print(' 共 %d 个' % len(unused))
print()
print('=' * 60)
print('通过 %d 项,失败 %d 项' % (PASS, FAIL))
sys.exit(1 if FAIL else 0)