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 项全绿
736 lines
30 KiB
Python
736 lines
30 KiB
Python
#!/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', ''),
|
||
],
|
||
}
|