feat(shared,web-admin,desktop-client,build): beUI 动效层与两端深色主题,全项目图标改用设计导出的新图标

动效层:把 beUI 的动效语言移植到两端,令牌放在 packages/shared/src/motion.ts(纯常量,后端也引用得到)。弹簧用数值积分生成 CSS linear(),纯 CSS 即可驱动,不必每组件一个 rAF 循环;两端各一份 composables/motion.ts 负责 DOM 部分,在 app.mount() 之前注入 :root。含 reduced-motion 全局降级与 hover 能力探测;灵动岛只移植单调曲线。

深色主题:Web 管理端补齐,机制与桌面客户端一致;语义色令牌只覆盖颜色,不动圆角与动效令牌。默认跟随系统,用户手动点过之后以用户为准。

图标:build/classhelper.{png,ico} 成为全项目唯一图形源,pnpm icons 从它派生各端图标与界面内品牌标。版本号统一到 1.1.2。
This commit is contained in:
LoveChengke committed 2026-10-06 15:32:01 +08:00
1 parent 82c85b59c1
commit ce9bed73f4
59 files changed
+2974 -336

No files matched your search

+193 -114
View File
@@ -1,157 +1,236 @@
/**
* 用 Electron 渲染 SVG 并导出应用图标(PNG + ICO)。
* 应用图标导出器(`pnpm icons`)—— **全项目所有图标都从这里出**。
*
* 为什么用 Electron:可以复用系统字体渲染中文标识,且无需任何图形库/ImageMagick。
* ## 图形源(唯一)
*
* 产物:
* packages/web-admin/public/icon-{192,512}.png PWA 图标
* packages/web-admin/public/apple-touch-icon.png iOS 图标
* packages/web-admin/public/favicon.svg 浏览器标签页图标
* packages/desktop-client/build/icon.png 512×512(electron-builder 用)
* packages/desktop-client/build/icon.ico 多尺寸 ICO(安装包/EXE 图标)
* build/icon.ico 服务端安装程序图标
* 仓库根 `build/` 下那两份是**手工放入的设计导出**,不要用 `pnpm icons` 去覆盖它们:
*
* build/classhelper.png 345×339 PNG(调色板 + tRNS,带透明)—— 所有 PNG 尺寸由它重采样;
* build/classhelper.ico 9 个尺寸(16/24/32/48/64/72/96/128/256,BMP 载荷)—— 由设计工具
* **逐尺寸栅格化**,比把位图缩下去干净,因此**原样透传**,不重新生成。
*
* 历史上这里是用 Electron 渲染一段内联 SVG("蓝色圆角块 + 白色『班』字")生成全套图标;
* 换成设计导出的位图之后,那套 SVG 已删除,本脚本只做重采样与分发。
*
* ## 产物
*
* packages/web-admin/public/icon-192.png PWA 图标
* packages/web-admin/public/icon-512.png PWA 图标(manifest 里也当 maskable 用)
* packages/web-admin/public/apple-touch-icon.png iOS 主屏图标(180)
* packages/web-admin/public/favicon.png 标签页图标(64)
* packages/web-admin/public/favicon.svg 标签页图标(内嵌 128 位图的 SVG 壳)
* packages/web-admin/public/logo.png **界面内**的品牌标(侧栏 / 登录页,128)
* packages/classisland-plugin/icon.png ClassIsland 插件图标(256)
* packages/desktop-client/build/classhelper.{png,ico} 图标源在客户端的镜像
* packages/desktop-client/build/icon.{png,ico} electron-builder 的**兜底约定名**
* (`win.icon` 显式指向 classhelper.ico,
* 这两个是与它同步的副本,别再删)
* packages/desktop-client/public/tray.png 系统托盘图标(32)
* packages/desktop-client/public/logo.png **界面内**的品牌标(侧栏 / 登录页,128)
* website/assets/icon.png 官网(favicon / apple-touch / 页面内 brand)
*
* ## 为什么还是 Electron
*
* 要把一张位图重采样到 8 个尺寸(其中 512 那档是**放大**),仓库里没有任何图形库;
* 而 Electron 自带 Chromium 的 canvas,重采样质量比 `nativeImage.resize()` 好得多
* (后者一步到位、没有逐级折半,345→16 会走样)。为这一个脚本引 sharp/jimp 进
* devDependencies 更不划算 —— 这条流水线从有图标那天起就是跑在 Electron 上的。
*
* ## 三条实现要点
*
* ① **非正方形源**:345×339 直接 `drawImage` 到正方形画布会拉变形,必须等比缩放后居中、
* 四周补透明(补边只有 1.7%,肉眼不可见);
* ② **逐级折半**:canvas 的降采样是一次双线性,345 → 16 一步到位会糊成一团,
* 先折到目标的两倍以内再出最后一步;
* ③ 图片以 `data:` URL 进页面:`data:` 图**不会污染 canvas**,`toDataURL()` 拿得到数据;
* 换成 `file://` 就会因跨源被拒(Chromium 里 file:// 页面是 opaque origin)。
*/
const fs = require('node:fs');
const path = require('node:path');
const { app, BrowserWindow } = require('electron');
const ROOT = path.resolve(__dirname, '..', '..');
const SIZES = [16, 24, 32, 48, 64, 128, 180, 192, 256, 512];
const BUILD_DIR = path.join(ROOT, 'build');
const SOURCE_PNG = path.join(BUILD_DIR, 'classhelper.png');
const SOURCE_ICO = path.join(BUILD_DIR, 'classhelper.ico');
/** 品牌图形:蓝色渐变圆角方块 + 白色"班"字 + 书本底纹 */
function logoSvg(size) {
const radius = Math.round(size * 0.22);
const fontSize = Math.round(size * 0.52);
const bookY = Math.round(size * 0.74);
const bookWidth = Math.round(size * 0.5);
const bookX = Math.round((size - bookWidth) / 2);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4facfe"/>
<stop offset="55%" stop-color="#409eff"/>
<stop offset="100%" stop-color="#1f6feb"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="url(#g)"/>
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="central"
font-family="'Microsoft YaHei','PingFang SC',sans-serif" font-weight="700"
font-size="${fontSize}" fill="#ffffff">班</text>
<rect x="${bookX}" y="${bookY}" width="${bookWidth}" height="${Math.max(2, Math.round(size * 0.045))}"
rx="${Math.round(size * 0.02)}" fill="#ffffff" opacity="0.9"/>
</svg>`;
/** 需要从源 PNG 重采样出来的全部尺寸(去重后每个尺寸只渲染一次) */
const PNG_SIZES = [32, 64, 128, 180, 192, 256, 512];
/** 客户端产物目录(electron-builder 的 buildResources) */
const DESKTOP_BUILD = path.join(ROOT, 'packages', 'desktop-client', 'build');
function readSourceDataUrl() {
if (!fs.existsSync(SOURCE_PNG)) {
throw new Error(`缺少图形源 ${path.relative(ROOT, SOURCE_PNG)}(设计导出,需手工放入)`);
}
return `data:image/png;base64,${fs.readFileSync(SOURCE_PNG).toString('base64')}`;
}
function htmlFor(size) {
return `<!doctype html><html><head><meta charset="utf-8" /><style>
html,body{margin:0;padding:0;background:transparent;width:${size}px;height:${size}px;overflow:hidden}
svg{display:block}
</style></head><body>${logoSvg(size)}</body></html>`;
/**
* 页面侧的重采样脚本。返回一张 `size×size`、内容等比居中、四周透明的 PNG(data URL)。
*
* 以字符串形式注入页面主世界执行(`executeJavaScript` 默认就在主世界,
* 不受 `contextIsolation` 影响),因此这里不能用反引号。
*/
function resampleScript(size, sourceUrl) {
return `(async () => {
const SIZE = ${size};
const img = new Image();
img.src = ${JSON.stringify(sourceUrl)};
await img.decode();
// 等比放进 SIZE×SIZE 的目标框
const fit = Math.min(SIZE / img.naturalWidth, SIZE / img.naturalHeight);
const boxW = Math.max(1, Math.round(img.naturalWidth * fit));
const boxH = Math.max(1, Math.round(img.naturalHeight * fit));
// 逐级折半:一步到位降采样会走样,先折到目标的两倍以内
let current = img;
while (Math.round(current.width / 2) >= boxW && Math.round(current.height / 2) >= boxH) {
const half = document.createElement('canvas');
half.width = Math.max(1, Math.round(current.width / 2));
half.height = Math.max(1, Math.round(current.height / 2));
const halfCtx = half.getContext('2d');
halfCtx.imageSmoothingEnabled = true;
halfCtx.imageSmoothingQuality = 'high';
halfCtx.drawImage(current, 0, 0, half.width, half.height);
current = half;
}
// 最后一次:按 current 的实际比例重算一次落位尺寸,避免折半取整带来的亚像素拉伸
const finalFit = Math.min(SIZE / current.width, SIZE / current.height);
const drawW = Math.max(1, Math.round(current.width * finalFit));
const drawH = Math.max(1, Math.round(current.height * finalFit));
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(current, Math.floor((SIZE - drawW) / 2), Math.floor((SIZE - drawH) / 2), drawW, drawH);
return canvas.toDataURL('image/png');
})()`;
}
/** 把若干 PNG 打包成 ICO 容器(ICO 允许直接内嵌 PNG 数据) */
function buildIco(entries) {
const count = entries.length;
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); // reserved
header.writeUInt16LE(1, 2); // type: icon
header.writeUInt16LE(count, 4);
const directory = Buffer.alloc(16 * count);
let offset = 6 + 16 * count;
const payloads = [];
entries.forEach((entry, index) => {
const base = index * 16;
directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, base + 0); // width
directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, base + 1); // height
directory.writeUInt8(0, base + 2); // palette
directory.writeUInt8(0, base + 3); // reserved
directory.writeUInt16LE(1, base + 4); // color planes
directory.writeUInt16LE(32, base + 6); // bits per pixel
directory.writeUInt32LE(entry.data.length, base + 8);
directory.writeUInt32LE(offset, base + 12);
offset += entry.data.length;
payloads.push(entry.data);
});
return Buffer.concat([header, directory, ...payloads]);
}
async function renderSizes() {
const window = new BrowserWindow({
width: 512,
height: 512,
show: false,
transparent: true,
frame: false,
webPreferences: { offscreen: false, backgroundThrottling: false },
});
const tempDir = path.join(ROOT, '.cache', 'icons');
fs.mkdirSync(tempDir, { recursive: true });
async function renderSizes(window, sourceUrl) {
const pngBySize = new Map();
for (const size of SIZES) {
const file = path.join(tempDir, `logo-${size}.html`);
fs.writeFileSync(file, htmlFor(size), 'utf8');
window.setContentSize(size, size);
await window.loadFile(file);
await new Promise((resolve) => setTimeout(resolve, 150));
let buffer = (await window.webContents.capturePage({ x: 0, y: 0, width: size, height: size })).toPNG();
// 隐藏窗口在部分环境下会截到空图,短暂显示后重试
if (!buffer || buffer.length === 0) {
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, 250));
buffer = (await window.webContents.capturePage({ x: 0, y: 0, width: size, height: size })).toPNG();
window.hide();
}
if (!buffer || buffer.length === 0) {
throw new Error(`渲染 ${size}×${size} 图标失败:截图为空`);
for (const size of PNG_SIZES) {
const dataUrl = await window.webContents.executeJavaScript(resampleScript(size, sourceUrl));
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
throw new Error(`渲染 ${size}×${size} 失败:页面没有返回 PNG data URL`);
}
const buffer = Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64');
if (buffer.length === 0) throw new Error(`渲染 ${size}×${size} 失败:数据为空`);
console.log(`[icons] ${size}×${size} -> ${buffer.length} 字节`);
pngBySize.set(size, buffer);
}
window.destroy();
return pngBySize;
}
/** ICO 容器头解析,只用来核对设计导出里到底带了哪几个尺寸 */
function readIcoSizes(buffer) {
if (buffer.length < 6 || buffer.readUInt16LE(0) !== 0 || buffer.readUInt16LE(2) !== 1) {
throw new Error(`${path.relative(ROOT, SOURCE_ICO)} 不是合法的 ICO 容器`);
}
const count = buffer.readUInt16LE(4);
const sizes = [];
for (let index = 0; index < count; index += 1) {
const base = 6 + index * 16;
sizes.push(`${buffer[base] || 256}×${buffer[base + 1] || 256}`);
}
return sizes;
}
/**
* 标签页图标的 SVG 壳。
*
* 设计只给了位图,没有矢量源 —— 所以这里不做"假矢量",而是把一张 128 的 PNG 原样嵌进去。
* 保留 `favicon.svg` 这个文件名是为了不动 `index.html` 里那条 `<link rel="icon" type="image/svg+xml">`:
* 现代浏览器优先取 SVG 那条,删掉它反而要改 HTML、改文档。
* 128 足够覆盖任何真实标签页尺寸(32px @4x = 128 物理像素)。
*/
function buildFaviconSvg(png128) {
const base64 = png128.toString('base64');
return `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<image href="data:image/png;base64,${base64}" width="128" height="128" />
</svg>
`;
}
/** 内容一致就不写盘:反复跑 `pnpm icons` 不该在 git 里刷出无意义的改动 */
function writeIfChanged(file, data) {
if (!data || (Buffer.isBuffer(data) && data.length === 0)) {
if (!data || data.length === 0) {
throw new Error(`拒绝写入空内容:${path.relative(ROOT, file)}`);
}
fs.mkdirSync(path.dirname(file), { recursive: true });
const existing = fs.existsSync(file) ? fs.readFileSync(file) : null;
if (existing && existing.equals(data)) {
console.log(`[icons] 未变化 ${path.relative(ROOT, file)}`);
return;
}
fs.writeFileSync(file, data);
console.log(`[icons] 写入 ${path.relative(ROOT, file)} (${data.length} 字节)`);
}
app.whenReady().then(async () => {
try {
const pngBySize = await renderSizes();
const sourceUrl = readSourceDataUrl();
const icoBytes = fs.readFileSync(SOURCE_ICO);
const icoSizes = readIcoSizes(icoBytes);
console.log(`[icons] 图形源 ${path.relative(ROOT, SOURCE_PNG)}`);
console.log(`[icons] ICO 尺寸 ${icoSizes.join(' / ')}`);
// Web 管理端(PWA)
// 尺寸由页面里的 canvas 决定,窗口本身多大都行 —— 用 64×64 就够,且必须隐藏
const window = new BrowserWindow({
width: 64,
height: 64,
show: false,
webPreferences: { offscreen: false, backgroundThrottling: false },
});
await window.loadURL(
`data:text/html;charset=utf-8,${encodeURIComponent('<!doctype html><meta charset="utf-8"><title>icons</title>')}`,
);
const pngBySize = await renderSizes(window, sourceUrl);
window.destroy();
// ── Web 管理端(PWA + 标签页) ───────────────────────────────────────────
const webPublic = path.join(ROOT, 'packages', 'web-admin', 'public');
writeIfChanged(path.join(webPublic, 'icon-192.png'), pngBySize.get(192));
writeIfChanged(path.join(webPublic, 'icon-512.png'), pngBySize.get(512));
writeIfChanged(path.join(webPublic, 'apple-touch-icon.png'), pngBySize.get(180));
writeIfChanged(path.join(webPublic, 'favicon.svg'), Buffer.from(logoSvg(64), 'utf8'));
writeIfChanged(path.join(webPublic, 'favicon.png'), pngBySize.get(64));
writeIfChanged(path.join(webPublic, 'favicon.svg'), Buffer.from(buildFaviconSvg(pngBySize.get(128)), 'utf8'));
// 界面内的品牌标(侧栏 / 登录页)。与 favicon 分开一份的理由:favicon 是给浏览器标签页用的,
// 而这张是**渲染进页面**的(`<img src="/logo.png">`),尺寸与缓存策略都不该混在一起。
writeIfChanged(path.join(webPublic, 'logo.png'), pngBySize.get(128));
// 桌面客户端(electron-builder)
const desktopBuild = path.join(ROOT, 'packages', 'desktop-client', 'build');
writeIfChanged(path.join(desktopBuild, 'icon.png'), pngBySize.get(512));
// ── ClassIsland 插件(清单同目录的 icon.png,ClassIsland 按约定读取) ──────
writeIfChanged(path.join(ROOT, 'packages', 'classisland-plugin', 'icon.png'), pngBySize.get(256));
// ICO(安装包 / EXE 图标):包含常用尺寸
const icoSizes = [16, 24, 32, 48, 64, 128, 256];
const ico = buildIco(icoSizes.map((size) => ({ size, data: pngBySize.get(size) })));
writeIfChanged(path.join(desktopBuild, 'icon.ico'), ico);
writeIfChanged(path.join(ROOT, 'build', 'icon.ico'), ico);
writeIfChanged(path.join(ROOT, 'build', 'icon.png'), pngBySize.get(512));
// ── 桌面客户端 ──────────────────────────────────────────────────────────
// classhelper.* 是源的镜像;icon.* 是 electron-builder 的兜底约定名(默认找 build/icon.ico),
// 两套都留在客户端产物目录里,免得哪天显式配置被拿掉就悄悄退回旧图标。
writeIfChanged(path.join(DESKTOP_BUILD, 'classhelper.png'), fs.readFileSync(SOURCE_PNG));
writeIfChanged(path.join(DESKTOP_BUILD, 'classhelper.ico'), icoBytes);
writeIfChanged(path.join(DESKTOP_BUILD, 'icon.png'), pngBySize.get(512));
writeIfChanged(path.join(DESKTOP_BUILD, 'icon.ico'), icoBytes);
// 托盘:Windows 通知区域在 100% DPI 下就是 16×16,给 32×32 让它在
// 150% / 200% DPI 上也有原生像素可用(原先直接丢了一张 345×339 的应用图标进去,
// 非正方形会被系统拉伸)。
writeIfChanged(path.join(ROOT, 'packages', 'desktop-client', 'public', 'tray.png'), pngBySize.get(32));
// 界面内的品牌标(侧栏 / 登录页)。
// **客户端必须经 `BRAND_LOGO_URL`(src/renderer/config.ts)绑定给 `:src`**:
// 生产环境是 file:// 加载 dist/renderer/index.html,根绝对路径会解析到盘符根目录;
// 而字面量的相对 `src` 又会被 Vite 当成模块导入去解析、直接构建失败。
writeIfChanged(path.join(ROOT, 'packages', 'desktop-client', 'public', 'logo.png'), pngBySize.get(128));
// ── 官网(纯静态站,图标同样是页面里的 brand 标记) ──────────────────────
writeIfChanged(path.join(ROOT, 'website', 'assets', 'icon.png'), pngBySize.get(512));
console.log('[icons] 全部图标生成完成');
app.exit(0);