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

736 lines
30 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
theme —— Drouter「主题之家」主题引擎(纯逻辑,零第三方依赖,可离线单测)
职责:
* 定义主题的数据结构(变量表 + 元信息)
* 严格校验一个主题包是否合法(不合法给出中文原因,绝不应用)
* 把主题包渲染成一段可用的 CSS(覆盖 :root 变量)
* 打包 / 解包 .zip 主题包(打包用内置 zipfile,解包做目录穿越与炸弹防护)
设计要点:
* 变量白名单(THEME_VARS)是唯一允许被主题覆盖的 CSS 自定义属性集合;
这样即使用户上传的主题包含恶意内容,也无法注入到规则体外(值里有严格字符校验)。
* 颜色值只接受 #rgb / #rrggbb / #rrggbbaa / rgb() / rgba() / hsl() / hsla()
/ 具名色,以及少数安全的关键字(transparent / currentColor / none)。
任何 `;{}<>`、`url(`、`expression(`、`@`、反斜杠、注释符号都会被拒绝。
* 数值类变量只接受「数字 + 单位」,单位白名单可控。
本模块被 drouter-helper.py import;不依赖 helper 的其它函数,便于单独测试。
"""
import io
import os
import re
import json
import time
import zipfile
# ------------------------------------------------------------------ 常量
MAX_THEME_BYTES = 512 * 1024 # 单个主题包解压后总大小上限(防 zip 炸弹)
MAX_ENTRY_BYTES = 256 * 1024 # 单个文件解压后大小上限
MAX_ENTRIES = 64 # 主题包内文件数量上限
MAX_NAME_LEN = 48 # 主题名长度
MAX_CSS_LEN = 64 * 1024 # 自定义 CSS 长度上限
THEME_EXT = '.drtheme'
# 主题变量白名单:变量名 -> 分类(用于前端分组显示)
# 只允许覆盖这些变量,其它变量主题无法触碰。
THEME_VARS = {
# 基础配色
'--bg': 'base', '--bg2': 'base', '--panel': 'base', '--panel2': 'base',
'--line': 'base', '--line2': 'base',
'--txt': 'base', '--txt2': 'base', '--txt3': 'base',
# 主色与状态色
'--pri': 'color', '--pri-d': 'color', '--pri-l': 'color',
# 主色本身若太浅(如暖橙 #f97316),当链接/强调文字用会看不清;
# --pri-text 是同一色调的「可读版本」,--pri 仍保留原色用于填充与按钮。
'--pri-text': 'color',
'--ok': 'color', '--ok-l': 'color',
'--warn': 'color', '--warn-l': 'color',
'--err': 'color', '--err-l': 'color',
'--info': 'color', '--info-l': 'color',
# 形状与阴影
'--r': 'shape', '--sh': 'shape', '--grad': 'shape',
'--side': 'layout', '--side-c': 'layout',
}
# 变量分类的中文名(前端下拉分组用)
THEME_CATS = [
('base', '基础配色'),
('color', '主色与状态色'),
('shape', '圆角 / 阴影 / 渐变'),
('layout', '布局尺寸'),
]
# 变量中文说明(前端提示)
THEME_VAR_CN = {
'--bg': '页面背景', '--bg2': '悬停背景', '--panel': '卡片背景', '--panel2': '表头背景',
'--line': '边框', '--line2': '浅分隔线',
'--txt': '主文字', '--txt2': '次要文字', '--txt3': '弱化文字',
'--pri': '主题主色', '--pri-d': '主色加深', '--pri-l': '主色浅底',
'--pri-text': '主色(文字用,自动加深以保证可读)',
'--ok': '成功色', '--ok-l': '成功浅底',
'--warn': '警告色', '--warn-l': '警告浅底',
'--err': '错误色', '--err-l': '错误浅底',
'--info': '信息色', '--info-l': '信息浅底',
'--r': '卡片圆角', '--sh': '卡片阴影', '--grad': '主按钮渐变',
'--side': '侧栏宽度', '--side-c': '侧栏折叠宽度',
}
# 值校验:颜色
# 0~255 的整数写法(避免 [0-9]{1,3} 放行 300 这类越界值)
_N255 = r'(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])'
# 透明度:0 / 1 / .5 / 0.35
_ALPHA = r'(?:0|1|0?\.\d{1,3})'
# 色相:0~360(可带小数或 deg 后缀)
_HUE = r'(?:\d|[1-9]\d|[12]\d{2}|3[0-5]\d|360)(?:\.\d+)?(?:deg)?'
# 百分比:0~100
_PCT = r'(?:\d|[1-9]\d|100)%'
_RE_HEX = re.compile(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
_RE_RGB = re.compile(r'^rgba?\(\s*%s\s*,\s*%s\s*,\s*%s(?:\s*,\s*%s)?\s*\)$'
% (_N255, _N255, _N255, _ALPHA))
_RE_HSL = re.compile(r'^hsla?\(\s*%s\s*,\s*%s\s*,\s*%s(?:\s*,\s*%s)?\s*\)$'
% (_HUE, _PCT, _PCT, _ALPHA))
_NAMED = {
'transparent', 'currentcolor', 'black', 'white', 'red', 'green', 'blue',
'gray', 'grey', 'silver', 'navy', 'teal', 'olive', 'purple', 'orange',
'yellow', 'pink', 'brown', 'lime', 'aqua', 'cyan', 'magenta', 'maroon',
'fuchsia', 'gold', 'indigo', 'violet', 'tomato', 'salmon', 'khaki',
'coral', 'crimson', 'seagreen', 'steelblue', 'slategray', 'slategrey',
'darkgrey', 'darkgray', 'lightgrey', 'lightgray', 'whitesmoke', 'ivory',
'beige', 'lavender', 'plum', 'orchid', 'turquoise', 'skyblue', 'royalblue',
'midnightblue', 'dodgerblue', 'forestgreen', 'darkgreen', 'darkred',
'darkorange', 'goldenrod', 'chocolate', 'sienna', 'peru', 'wheat',
}
# 值的最大长度(防止超长垃圾串)
MAX_VAL_LEN = 220
# 主题名允许的字符:中文、字母、数字、空格、- _ . ( )
_RE_NAME = re.compile(r'^[\u4e00-\u9fa5A-Za-z0-9 _.\-()]{1,%d}$' % MAX_NAME_LEN)
# 主题 id(用于目录名/文件名):仅小写字母数字与 -
_RE_ID = re.compile(r'^[a-z0-9][a-z0-9\-]{1,63}$')
# 十六进制色(用于自动生成配色方案)
_RE_HEX_STRICT = re.compile(r'^#([0-9a-fA-F]{6})$')
# ------------------------------------------------------------------ 校验
def _bad(msg, code='BAD_THEME'):
return {'ok': False, 'code': code, 'msg_cn': msg}
def valid_css_value(v, var=''):
"""校验一个 CSS 变量取值是否安全。返回 (ok, 中文原因)"""
if v is None:
return False, '取值不能为空'
s = str(v).strip()
if not s:
return False, '取值不能为空'
if len(s) > MAX_VAL_LEN:
return False, '取值过长(最多 %d 字符)' % MAX_VAL_LEN
low = s.lower()
# 危险字符 / 语法
for ch in (';', '{', '}', '<', '>', '@', '\\', '!', '`'):
if ch in s:
return False, '包含非法字符「%s」' % ch
if '/*' in s or '*/' in s:
return False, '不允许出现注释符号'
if 'url(' in low or 'expression' in low or 'import' in low or 'javascript' in low:
return False, '不允许使用 url() / expression / import 等外部引用'
# 变量引用(--x: var(--y))——只允许引用白名单内的变量
if low.startswith('var('):
m = re.match(r'^var\(\s*(--[a-z0-9\-]+)\s*(?:,[^)]*)?\)$', low)
if not m:
return False, 'var() 引用写法不正确'
if m.group(1) not in THEME_VARS:
return False, '不允许引用未开放的主题变量「%s」' % m.group(1)
return True, ''
# 颜色
if s.startswith('#'):
return (_RE_HEX.match(s) is not None or
_RE_HSL.match(low) is not None, '颜色格式不正确(支持 #rgb / #rrggbb / #rrggbbaa)'
if not _RE_HEX.match(s) else '')
if low.startswith('rgb') or low.startswith('hsl'):
if _RE_RGB.match(low) or _RE_HSL.match(low):
return True, ''
return False, '颜色函数格式不正确(示例:rgba(31,111,235,.35))'
if low in _NAMED:
return True, ''
# 数值 + 单位(圆角 / 宽度)
if re.match(r'^\d+(?:\.\d+)?(px|rem|em|%|vh|vw|pt|ch)$', low):
if var and var in ('--r', '--side', '--side-c'):
if low.endswith(('vh', 'vw')):
return False, '该变量不支持 vh / vw 单位'
return True, ''
if low == '0':
return True, ''
# 阴影 / 渐变等复合值:做保守的「字符白名单」检查
if re.match(r'^[0-9a-zA-Z#(),.\s%\-]+$', s):
if low.startswith('linear-gradient(') or low.startswith('radial-gradient('):
if 'gradient(' not in low:
return False, '渐变写法不正确'
return True, ''
# 阴影:允许 "0 1px 2px rgba(0,0,0,.1), 0 2px 8px rgba(...)"
if re.match(r'^(?:(?:-?\d+(?:\.\d+)?(?:px|em|rem)?|rgba?\([^)]*\)|hsla?\([^)]*\)|'
r'#[0-9a-fA-F]{3,8}|inset|none|,|\s)+)$', s):
return True, ''
return False, '取值格式无法识别,请使用颜色 / 数值(带单位) / var(--已开放变量)'
def validate_theme(obj):
"""校验主题对象。返回 (ok, 错误消息 / 规范化后的主题)"""
if not isinstance(obj, dict):
return False, '主题内容必须是一个 JSON 对象'
name = str(obj.get('name') or '').strip()
if not name:
return False, '缺少主题名称(name)'
if not _RE_NAME.match(name):
return False, ('主题名称只能包含中文、字母、数字、空格与 - _ . ( ),'
'且不超过 %d 个字符' % MAX_NAME_LEN)
desc = str(obj.get('description') or '').strip()
if len(desc) > 300:
return False, '主题描述过长(最多 300 字)'
author = str(obj.get('author') or '').strip()
if len(author) > 64:
return False, '作者名过长(最多 64 字)'
ver = str(obj.get('version') or '1.0').strip()
if len(ver) > 24:
return False, '版本号过长'
dark = bool(obj.get('dark'))
vars_in = obj.get('vars')
if vars_in is None:
vars_in = {}
if not isinstance(vars_in, dict):
return False, 'vars 必须是一个「变量名 → 取值」的对象'
if len(vars_in) > len(THEME_VARS):
return False, '变量数量超出上限'
clean_vars = {}
for k, v in vars_in.items():
kk = str(k).strip()
if not kk.startswith('--'):
kk = '--' + kk
if kk not in THEME_VARS:
return False, '不支持的变量「%s」(不在可定制变量清单内)' % kk
if isinstance(v, dict):
# 允许 {"value": "...", "on": true} 形式
if v.get('on') is False:
continue
v = v.get('value')
vv = str(v).strip() if v is not None else ''
if not vv:
continue
good, why = valid_css_value(vv, kk)
if not good:
return False, '变量「%s」取值不合法:%s' % (kk, why)
clean_vars[kk] = vv
if not clean_vars:
return False, '主题至少要定义 1 个变量'
extra = str(obj.get('css') or '')
if extra:
if len(extra) > MAX_CSS_LEN:
return False, '自定义 CSS 过长(最多 %d 字节)' % MAX_CSS_LEN
# 自定义 CSS 只允许「选择器 { 声明 }」形式,禁止 @ 规则 / 脚本 / 外部引用
bad, why = _check_extra_css(extra)
if bad:
return False, why
theme = {
'id': '', # 由调用方分配
'name': name,
'description': desc,
'author': author,
'version': ver,
'dark': dark,
'vars': clean_vars,
'css': extra,
'builtin': bool(obj.get('builtin')),
'created_at': obj.get('created_at') or '',
'updated_at': obj.get('updated_at') or '',
}
return True, theme
def _check_extra_css(css):
"""额外 CSS 的安全检查:只允许平面选择器 + 声明块,禁止嵌套与 at-rule。"""
s = re.sub(r'/\*.*?\*/', '', css, flags=re.S) # 去注释
if '@' in s:
return True, '自定义 CSS 不允许使用 @ 规则(如 @import / @media)'
if '\\' in s:
return True, '自定义 CSS 不允许使用转义字符'
if re.search(r'(?i)(javascript\s*:|expression\s*\(|behavior\s*:|url\s*\()', s):
return True, '自定义 CSS 不允许使用 url() / expression() / javascript: 等外部引用'
# 粗略结构校验:整体必须能拆成 selector{...} 序列
rest = s.strip()
guard = 0
while rest:
guard += 1
if guard > 200:
return True, '自定义 CSS 结构过于复杂'
m = re.match(r'^([^{}]+)\{([^{}]*)\}', rest, re.S)
if not m:
return True, '自定义 CSS 语法不正确:应为「选择器 { 属性: 取值; }」形式'
sel = m.group(1).strip()
body = m.group(2)
if not sel:
return True, '自定义 CSS 存在空选择器'
if re.search(r'[<>]', sel):
return True, '自定义 CSS 选择器包含非法字符'
# 声明体:prop: value; 逐条检查
for decl in body.split(';'):
decl = decl.strip()
if not decl:
continue
if ':' not in decl:
return True, '自定义 CSS 声明缺少冒号:%s' % decl[:40]
prop, val = decl.split(':', 1)
prop = prop.strip().lower()
if not re.match(r'^(--[a-z0-9\-]+|[a-z\-]+)$', prop):
return True, '自定义 CSS 属性名不合法:%s' % prop[:40]
if not re.match(r'^[0-9a-zA-Z#(),.\s%\-"/_]+$', val.strip()):
return True, '自定义 CSS 取值含非法字符:%s' % decl[:40]
rest = rest[m.end():].strip()
return False, ''
# ------------------------------------------------------------------ 渲染
def theme_to_css(theme):
"""把主题渲染成一段 CSS 文本(可直接写盘并 <link> 引入)。
始终包在 :root{...} 里;自定义 CSS 追加在后面。
同时输出一份深/浅色标记注释,便于排查。
"""
lines = []
# 主题 meta(名称 / 版本)是自由文本,原样拼进 /* */ 注释时,一个 "*/"
# 就能提前闭合注释,让后面的内容变成真正的 CSS 规则(主题名写成
# `x*/}body{display:none}/*` 即可注入)。先剥掉注释终止符与换行。
def _cmt(s):
return str(s or '').replace('*/', '').replace('\r', ' ').replace('\n', ' ')
lines.append('/* Drouter 主题:%s%s —— 由「主题之家」生成,请勿手工编辑 */'
% (_cmt(theme.get('name') or '未命名'),
(' v' + _cmt(theme['version'])) if theme.get('version') else ''))
lines.append('/* 生成时间:%s */' % time.strftime('%Y-%m-%d %H:%M:%S'))
lines.append(':root{')
for k in sorted(theme.get('vars') or {}):
lines.append(' %s:%s;' % (k, (theme['vars'][k] or '').strip()))
lines.append('}')
dark = bool(theme.get('dark'))
lines.append(':root{color-scheme:%s}' % ('dark' if dark else 'light'))
extra = (theme.get('css') or '').strip()
if extra:
lines.append('')
lines.append('/* ---- 作者自定义 CSS ---- */')
lines.append(extra)
lines.append('')
return '\n'.join(lines)
# ------------------------------------------------------------------ 内置主题
def _hex_rgb(h):
h = h.lstrip('#')
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _rgb_hex(r, g, b):
return '#%02x%02x%02x' % (max(0, min(255, int(round(r)))),
max(0, min(255, int(round(g)))),
max(0, min(255, int(round(b)))))
def mix(c1, c2, t):
"""把 c1 往 c2 混合 t(0~1)"""
r1, g1, b1 = _hex_rgb(c1)
r2, g2, b2 = _hex_rgb(c2)
return _rgb_hex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t)
def tone(base, t, dark=False):
"""生成一个「基色 + 混合目标」的色调:t>0 提亮,t<0 加深"""
if dark:
return mix(base, '#ffffff', t) if t > 0 else mix(base, '#000000', -t)
return mix(base, '#ffffff', t) if t > 0 else mix(base, '#000000', -t)
def _lum(rgb):
def f(v):
v /= 255.0
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
r, g, b = [f(x) for x in rgb]
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast(fg, bg):
"""WCAG 相对亮度对比度(1.0 ~ 21.0)"""
la, lb = _lum(_hex_rgb(fg)), _lum(_hex_rgb(bg))
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
def ensure_contrast(fg, bg, target=4.5):
"""保证 fg 在 bg 上的对比度不低于 target(对任意主色都要成立)。
用户可以在设计器里挑任意主色,派生色一旦压得太浅,文字就会糊。
这里按「远离背景」的方向逐级调整直到达标,最多 40 级必然收敛。
方向由背景自身的亮度决定(而不是调用方传的「是否深色模式」)——
深色主题里 --pri-l 反而可能是浅色底,按模式判断会得到完全相反的调整方向。
"""
try:
_hex_rgb(fg)
_hex_rgb(bg)
except Exception:
return fg
if contrast(fg, bg) >= target:
return fg
to_white = _lum(_hex_rgb(bg)) < 0.18 # 背景偏暗 → 文字往白走
step = 0.04
for _ in range(40):
fg = mix(fg, '#ffffff' if to_white else '#000000', step)
if contrast(fg, bg) >= target:
return fg
return ('#ffffff' if to_white else '#000000')
def make_palette(primary, dark=False, name='自定义主题', desc='由主题设计器生成',
deep=None):
"""由单一主色生成一套完整、可用的配色方案(保证对比度不会太离谱)。
deep:可选的深色底色。仅在 dark=True 时生效 —— 用来让多个深色主题
彼此拉开辨识度(否则每个 dark 主题都共用同一套 #111827 底色,
换个主色后整体观感几乎一样)。给了就用它派生出整套背景层次,
文字色仍按原逻辑校正对比度。
"""
p = primary if _RE_HEX_STRICT.match(str(primary or '')) else '#1f6feb'
if dark:
# 深色主题里 pri-l 用作「选中项浅底」,应当是主色的深色微透明感的版本,
# 而不是往白里混(那样会在深色背景上糊出一块亮斑)。
pri_l = mix(p, '#0b1220', 0.84)
else:
pri_l = tone(p, 0.86)
pri_d = tone(p, -0.18)
if dark:
# deep 给的是「页面背景」这一档,其余层次由它按同一方向递进:
# bg2 更亮一档做悬停,panel 介于两者之间做卡片,panel2 再亮一档做表头。
b0 = deep if (_RE_HEX_STRICT.match(str(deep or ''))) else '#111827'
bg = b0
bg2 = tone(b0, 0.07)
panel = tone(b0, 0.035)
panel2 = tone(b0, 0.075)
line = tone(b0, 0.16)
line2 = tone(b0, 0.115)
txt = '#e8eef8'
txt2 = '#a8b6cc'
txt3 = '#7b8aa3'
ok, warn, err, info = '#3ddc84', '#f5c451', '#ff6b5e', '#4fb8f0'
# 浅底跟着 b0 走:状态标签底色要在该主题的深蓝上调,
# 沿用通用深绿/深红那一组会与藏青底割裂。
# 关键:四档必须**互不相同**且能彼此区分 —— 早先只用「提亮 3~5%」派生,
# ok_l 与 err_l 算出来是同一个值,成功/失败标签底色撞在一起,
# 状态只剩字色可辨。这里改为「提亮 + 向各自状态色偏」双通道,
# 幅度仍压得很低(不抢面板),但色相分开。
def _tint(c, k=0.16):
return mix(mix(b0, c, k), '#ffffff', 0.035)
ok_l, warn_l, err_l, info_l = (_tint(ok), _tint(warn),
_tint(err), _tint(info))
else:
bg = mix(p, '#ffffff', 0.945)
bg2 = mix(p, '#ffffff', 0.905)
panel = '#ffffff'
panel2 = mix(p, '#ffffff', 0.965)
line = mix(p, '#ffffff', 0.83)
line2 = mix(p, '#ffffff', 0.93)
txt = '#161f30'
txt2 = mix(p, '#556480', 0.35)
txt3 = '#8894a8'
ok, warn, err, info = '#12813f', '#9a6700', '#c0392b', '#0b6e99'
ok_l, warn_l, err_l, info_l = '#e3f7ea', '#fff8e1', '#fdecea', '#e5f4fb'
# 可读性兜底:底色是由主色稀释而来,主色越浅,派生文字色越容易看不清。
# 三级文字统一按 WCAG AA(正文 4.5)校正,并用阶梯式目标保住层次感:
# 主文字 7.0 / 次要 5.5 / 弱化 4.5 —— 都 ≥ 4.5 且不会被拉平到同一个色。
txt = ensure_contrast(txt, panel, 7.0)
txt = ensure_contrast(txt, bg, 7.0)
txt2 = ensure_contrast(txt2, bg, 5.5)
txt2 = ensure_contrast(txt2, panel, 5.5)
txt3 = ensure_contrast(txt3, bg, 4.5)
txt3 = ensure_contrast(txt3, panel, 4.5)
# 主色本身也会被当文字用(选中菜单项、链接、强调色),
# 必须保证它在自己的浅底 pri-l 上仍然清晰。
_pri_safe = ensure_contrast(p, pri_l, 4.5)
p_txt = _pri_safe # 用于文字的主色
grad = 'linear-gradient(135deg,%s 0%%,%s 45%%,%s 100%%)' % (tone(p, 0.16), p, pri_d)
return {
'name': name, 'description': desc, 'author': 'Drouter 内置', 'version': '1.0',
'dark': dark,
'vars': {
'--bg': bg, '--bg2': bg2, '--panel': panel, '--panel2': panel2,
'--line': line, '--line2': line2,
'--txt': txt, '--txt2': txt2, '--txt3': txt3,
'--pri': p, '--pri-d': pri_d, '--pri-l': pri_l,
# p_txt:保证可读的主色变体;p 本身仍是「填充用」的原色
'--pri-text': p_txt,
'--ok': ok, '--ok-l': ok_l, '--warn': warn, '--warn-l': warn_l,
'--err': err, '--err-l': err_l, '--info': info, '--info-l': info_l,
'--r': '12px', '--side': '236px', '--side-c': '68px',
'--sh': '0 1px 2px rgba(18,28,55,.05),0 2px 8px rgba(18,28,55,.05)',
'--grad': grad,
},
'css': '',
}
def builtin_themes():
"""返回内置主题列表(id 固定,不可删除)。"""
out = []
t = make_palette('#1f6feb', False, 'Drouter 经典蓝', '默认主题,蓝色系,浅色界面')
t['id'] = 'default'
t['builtin'] = True
out.append(t)
t = make_palette('#0d9488', False, '青竹', '清爽的青绿色,适合长时间查看')
t['id'] = 'teal'
t['builtin'] = True
out.append(t)
t = make_palette('#7c3aed', False, '紫罗兰', '高对比度的紫色调')
t['id'] = 'violet'
t['builtin'] = True
out.append(t)
t = make_palette('#0f766e', True, '暗夜墨绿', '深色主题,夜间查看更护眼')
t['id'] = 'dark-green'
t['builtin'] = True
out.append(t)
t = make_palette('#f97316', False, '暖阳', '暖橙色调,明亮活泼')
t['id'] = 'warm'
t['builtin'] = True
out.append(t)
t = make_palette('#e11d48', True, '暗夜绯红', '深色 + 绯红点缀')
t['id'] = 'dark-rose'
t['builtin'] = True
out.append(t)
# 电光蓝:亮青蓝(#00b3ff)饱和度高、发光感强。浅色底上 #00b3ff 本身
# 对比度偏低,make_palette 里的 ensure_contrast 会把 --pri-text 自动压深,
# 保证选中项文字、链接仍然清晰 —— 主色本身仍保留亮蓝用于填充与渐变。
t = make_palette('#00b3ff', False, '电光蓝',
'高饱和青蓝,带发光质感,适合看清状态灯与图表')
t['id'] = 'electric-blue'
t['builtin'] = True
out.append(t)
# 藏青色:深蓝近黑的背景,介于「暗夜墨绿」的绿与「暗夜绯红」的红之间,
# 是长时间盯盘最不刺眼的一档;主色用偏亮的钴蓝做点缀,保证可读。
# deep 给了独立的深蓝底(#0b1220 → #101a33 这一带),
# 否则会和其他 dark 主题共用 #111827,换主色后整体观感几乎一样。
t = make_palette('#3b6fd4', True, '藏青色',
'深蓝近黑,护眼低眩光,适合夜间与长时间值守',
deep='#0d1526')
t['id'] = 'navy'
t['builtin'] = True
out.append(t)
for x in out:
x['created_at'] = ''
x['updated_at'] = ''
return out
# ------------------------------------------------------------------ ZIP 打包 / 解包
THEME_MANIFEST = 'theme.json'
def pack_theme_zip(theme):
"""把主题打成 zip 字节流:theme.json + theme.css + README.txt"""
if not isinstance(theme, dict):
return None
buf = io.BytesIO()
manifest = {
'schema': 'drouter.theme/1',
'name': theme.get('name') or '未命名主题',
'description': theme.get('description') or '',
'author': theme.get('author') or '',
'version': theme.get('version') or '1.0',
'dark': bool(theme.get('dark')),
'vars': theme.get('vars') or {},
'css': theme.get('css') or '',
'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'),
}
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr(THEME_MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2))
z.writestr('theme.css', theme_to_css(theme))
z.writestr('README.txt', _readme_text(manifest))
return buf.getvalue()
def _readme_text(m):
return (
'Drouter 主题包\n'
'================\n\n'
'名称:%s\n'
'版本:%s\n'
'作者:%s\n'
'模式:%s\n\n'
'说明:%s\n\n'
'使用方法\n'
'--------\n'
'1. 打开 Drouter 管理台 → 系统 → 主题之家\n'
'2. 点击「导入主题包」,选择本 .zip 文件\n'
'3. 校验通过后点击「应用」即可\n\n'
'包结构\n'
'------\n'
'theme.json 主题清单(变量与元信息,必需)\n'
'theme.css 由 theme.json 渲染出的样式(仅供参考,导入时会重新生成)\n'
'README.txt 本说明\n\n'
'可定制的变量(仅下列变量会被接受,其它一律拒绝):\n'
'%s\n'
) % (m.get('name'), m.get('version'), m.get('author') or '未署名',
'深色' if m.get('dark') else '浅色', m.get('description') or '(无)',
'\n'.join(' %-12s %s' % (k, THEME_VAR_CN.get(k, '')) for k in sorted(THEME_VARS)))
def _safe_zip_member(name):
"""拒绝绝对路径与目录穿越。返回规范化后的相对路径。"""
if not name:
return None
n = name.replace('\\', '/')
if n.startswith('/') or n.startswith('~'):
return None
while n.startswith('./'):
n = n[2:]
parts = [x for x in n.split('/') if x not in ('', '.')]
if any(x == '..' for x in parts):
return None
if not parts:
return None
return '/'.join(parts)
def unpack_theme_zip(data):
"""解析主题 zip。返回 (ok, 主题对象 / 中文错误)"""
if not data:
return False, '文件内容为空'
if len(data) > 4 * 1024 * 1024:
return False, '文件过大(上传上限 4 MB)'
try:
z = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile:
return False, '这不是一个有效的 ZIP 压缩包(请确认文件未损坏)'
except Exception as e:
return False, '无法读取压缩包:%s' % e
with z:
names = z.namelist()
if len(names) > MAX_ENTRIES:
return False, '压缩包内文件过多(上限 %d 个)' % MAX_ENTRIES
total = 0
manifest = None
manifest_path = None
for info in z.infolist():
if info.is_dir():
continue
safe = _safe_zip_member(info.filename)
if safe is None:
return False, '压缩包内存在不安全的路径:%s' % info.filename
if info.file_size > MAX_ENTRY_BYTES:
return False, '压缩包内单个文件过大:%s' % info.filename
total += info.file_size
if total > MAX_THEME_BYTES:
return False, '压缩包解压后总体积过大(上限 %d KB)' % (MAX_THEME_BYTES // 1024)
base = safe.split('/')[-1].lower()
if base in (THEME_MANIFEST, 'theme.json', 'drouter-theme.json', 'manifest.json'):
if manifest is None or base == THEME_MANIFEST:
manifest_path = info.filename
if manifest_path is None:
return False, ('压缩包里没有找到 theme.json —— '
'请确认这是由「主题之家」导出的主题包,或按说明手工创建 theme.json')
try:
raw = z.read(manifest_path).decode('utf-8-sig')
except UnicodeDecodeError:
return False, 'theme.json 编码不是 UTF-8,请另存为 UTF-8 后重试'
except Exception as e:
return False, '读取 theme.json 失败:%s' % e
try:
manifest = json.loads(raw)
except Exception as e:
return False, 'theme.json 不是合法的 JSON:%s' % e
good, res = validate_theme(manifest)
if not good:
return False, res
res['imported_from'] = os.path.basename(str(manifest_path))
return True, res
# ------------------------------------------------------------------ 工具
def slugify(name, fallback='theme'):
"""把主题名转成安全的目录/文件名片段。"""
s = re.sub(r'[^A-Za-z0-9\u4e00-\u9fa5\-]+', '-', str(name or '')).strip('-').lower()
s = re.sub(r'-{2,}', '-', s)
if not s or len(s) < 2:
return fallback
return s[:48]
def theme_id_from_name(name, taken=()):
"""生成不冲突的主题 id(自定义主题用)。"""
base = slugify(name)
# id 只允许小写字母数字与 -
base = re.sub(r'[^a-z0-9\-]+', '-', base).strip('-')
if not base or len(base) < 2:
base = 'theme'
base = base[:48]
if base not in taken and _RE_ID.match(base):
return base
i = 2
while True:
cand = '%s-%d' % (base[:40], i)
if cand not in taken and _RE_ID.match(cand):
return cand
i += 1
if i > 999:
return 'theme-%d' % int(time.time())
def summarize(theme):
"""给前端用的精简结构(不含完整 css,减少传输量)。"""
return {
'id': theme.get('id') or '',
'name': theme.get('name') or '',
'description': theme.get('description') or '',
'author': theme.get('author') or '',
'version': theme.get('version') or '',
'dark': bool(theme.get('dark')),
'builtin': bool(theme.get('builtin')),
'vars_count': len(theme.get('vars') or {}),
'has_css': bool((theme.get('css') or '').strip()),
'created_at': theme.get('created_at') or '',
'updated_at': theme.get('updated_at') or '',
# 缩略图需要的几个关键色
'swatch': [
(theme.get('vars') or {}).get('--pri', ''),
(theme.get('vars') or {}).get('--bg', ''),
(theme.get('vars') or {}).get('--panel', ''),
(theme.get('vars') or {}).get('--txt', ''),
],
}