feat(branding): 确定项目图标,并接入 Web / Electron / HarmonyOS

# 唯一源

`client/electron/src/icons/agentmail.svg` 是图标唯一源(24×24 视图框,`currentColor`
跟随文字色)。此前各端用的都是占位物:Electron 的窗口/托盘指向一个**不存在**的
`src/icons/tray-icon.png`(`nativeImage` 拿到空图,托盘不可见),
HarmonyOS 的 `startIcon/foreground/background` 是 1×1 PNG,
Web 端根本没有 favicon。

# 为什么带生成脚本

PNG/ICO 是二进制的,换一次配色要重出十几个尺寸,手工做必然出现
「Web 是旧的、Harmony 是新的」这种不一致,而且没人能复核。
`generate.py` 只认上面那一份源,所有变体都由它推导(本机无 rsvg/ImageMagick,
用 cairosvg + Pillow)。改图标只需改源文件再跑一次脚本。

# 各端产物

- **Web**:`public/assets/{agentmail.svg,favicon.ico,apple-touch-icon.png}` + `index.html` 引用。
  放 `assets/` 下而非根目录,是因为 Gateway 只把 `/assets/*` 与 `/` 交给静态处理器
  (`server/cmd/server/main.go`),放根下会 404。已实测本机与 LAN 均 200。
- **Electron**:应用图标 `icon.png`(512) / `icon.ico`(16–256) / 各尺寸 PNG /
  托盘 `tray-icon.png`(32),`package.json` 里 `win.icon` 与 `linux.icon` 指过去。
  托盘用品牌色字形而非白色 —— 浅色面板下白色会消失。
- **HarmonyOS**:`startIcon.png`(512) 用完整应用图标(启动页底色浅 `#FFF` /
  深 `#000`,白底蓝图标两套都立得住);分层图标的 `background` 是品牌色整块、
  `foreground` 是白色字形并留 12% 安全区,避免被系统圆角裁掉。
- **应用内**:新增 `BrandMarkIcon`(fill 型,与现有描边图标集不同族),
  替换登录页与初始化页品牌位的占位 `MailboxIcon`;后者已无引用,一并删除。

图标色 `#2563eb` 与门户 Dashy 主题主色一致。

# 验证

- 前端 typecheck 与 196 项测试全绿;`npm run build` 产物含三个图标文件
- Gateway 重新部署后 `/assets/{agentmail.svg,favicon.ico,apple-touch-icon.png}`
  在本机与 `192.168.2.60:8180` 都返回 200,Content-Type 正确
- 所有 PNG/ICO 用 Pillow 复核尺寸与 alpha 边界(合成失败会表现为全透明,
  已用 getbbox 排除)
This commit is contained in:
2026-09-11 15:49:41 +08:00
parent 4186ad4784
commit a404cbad54
26 changed files with 196 additions and 18 deletions

8
.gitignore vendored
View File

@ -53,6 +53,14 @@ attachments/
.pi-glla/ .pi-glla/
.codegraph .codegraph
# ---- Python 工具缓存 ----
#
# client/electron/src/icons/generate.py 是图标生成脚本cairosvg + Pillow
# 静态检查器跑 ruff 时会在同目录落 .ruff_cache/,不是项目内容。
.ruff_cache/
__pycache__/
*.pyc
# ---- 编辑器与系统 ---- # ---- 编辑器与系统 ----
.DS_Store .DS_Store
*.swp *.swp

View File

@ -1,8 +1,21 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<!--
图标放在 public/assets/ 而不是 public/ 根下Gateway 只把 /assets/* 和 /
挂给了静态处理器(见 server/cmd/server/main.go 的静态段),放在根下会 404。
Vite 原样拷贝 public/ 到 dist/,所以开发态与内嵌后 URL 都是 /assets/…。
品牌源文件与其生成脚本client/electron/src/icons/。
-->
<link rel="icon" type="image/svg+xml" href="/assets/agentmail.svg" />
<link rel="alternate icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
<meta name="theme-color" content="#2563eb" />
<title>AgentMail</title> <title>AgentMail</title>
<script> <script>
/* /*
@ -18,15 +31,15 @@
*/ */
(function () { (function () {
try { try {
var p = localStorage.getItem('agentmail.theme') || 'system'; var p = localStorage.getItem("agentmail.theme") || "system";
var dark = var dark =
p === 'dark' || p === "dark" ||
(p === 'system' && (p === "system" &&
window.matchMedia && window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches); window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) { if (dark) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = 'dark'; document.documentElement.style.colorScheme = "dark";
} }
} catch (e) { } catch (e) {
/* localStorage 不可用(隐私模式)时退回浅色,不阻塞渲染 */ /* localStorage 不可用(隐私模式)时退回浅色,不阻塞渲染 */

View File

@ -56,11 +56,13 @@
"output": "release" "output": "release"
}, },
"win": { "win": {
"icon": "src/icons/icon.ico",
"target": [ "target": [
{ "target": "nsis", "arch": ["x64"] } { "target": "nsis", "arch": ["x64"] }
] ]
}, },
"linux": { "linux": {
"icon": "src/icons",
"target": [ "target": [
{ "target": "AppImage", "arch": ["x64"] }, { "target": "AppImage", "arch": ["x64"] },
{ "target": "deb", "arch": ["x64"] } { "target": "deb", "arch": ["x64"] }

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="#2563eb" transform="translate(0,0) scale(1)"><path fill="#2563eb" d="M12 1.5a1.25 1.25 0 0 1 1.25 1.25c0 .5-.29.93-.71 1.13V5h1.71A2.75 2.75 0 0 1 17 7.75V8h2.5A2.5 2.5 0 0 1 22 10.5v9a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 19.5v-9A2.5 2.5 0 0 1 4.5 8H7v-.25A2.75 2.75 0 0 1 9.75 5h1.71V3.88c-.42-.2-.71-.63-.71-1.13A1.25 1.25 0 0 1 12 1.5m-2.25 5A1.25 1.25 0 0 0 8.5 7.75V8h7v-.25a1.25 1.25 0 0 0-1.25-1.25zM4.5 9.5a1 1 0 0 0-1 1v.34l8.5 4.72l8.5-4.72V10.5a1 1 0 0 0-1-1zm-1 3.05v6.95a1 1 0 0 0 1 1h15a1 1 0 0 0 1-1v-6.95l-8.5 4.72z"/><circle cx="9" cy="11.6" r=".9" fill="#2563eb"/><circle cx="15" cy="11.6" r=".9" fill="#2563eb"/></g></svg>

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { MailboxIcon, SpinnerIcon } from './icons'; import { BrandMarkIcon, SpinnerIcon } from './icons';
export default function LoginPage() { export default function LoginPage() {
const login = useAuthStore(s => s.login); const login = useAuthStore(s => s.login);
@ -56,7 +56,7 @@ export default function LoginPage() {
<div className="w-[380px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8"> <div className="w-[380px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8">
<div className="flex flex-col items-center mb-6"> <div className="flex flex-col items-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center"> <div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<MailboxIcon className="w-6 h-6" /> <BrandMarkIcon className="w-6 h-6" />
</div> </div>
<h1 className="mt-3 text-base font-semibold text-gray-900">AgentMail</h1> <h1 className="mt-3 text-base font-semibold text-gray-900">AgentMail</h1>
<p className="mt-1 text-xs text-gray-500"></p> <p className="mt-1 text-xs text-gray-500"></p>

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client'; import * as api from '../api/client';
import { MailboxIcon, SpinnerIcon } from './icons'; import { BrandMarkIcon, SpinnerIcon } from './icons';
/** 首次初始化向导:系统无任何用户时展示,创建首个管理员 */ /** 首次初始化向导:系统无任何用户时展示,创建首个管理员 */
export default function SetupPage({ onDone }: { onDone: () => void }) { export default function SetupPage({ onDone }: { onDone: () => void }) {
@ -56,7 +56,7 @@ export default function SetupPage({ onDone }: { onDone: () => void }) {
<div className="w-[420px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8"> <div className="w-[420px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8">
<div className="flex flex-col items-center mb-6"> <div className="flex flex-col items-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center"> <div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<MailboxIcon className="w-6 h-6" /> <BrandMarkIcon className="w-6 h-6" />
</div> </div>
<h1 className="mt-3 text-base font-semibold text-gray-900"></h1> <h1 className="mt-3 text-base font-semibold text-gray-900"></h1>
<p className="mt-1 text-xs text-gray-500 text-center"> <p className="mt-1 text-xs text-gray-500 text-center">

View File

@ -170,13 +170,19 @@ export function LockIcon({ className = 'w-4 h-4' }: P) {
); );
} }
export function MailboxIcon({ className = 'w-8 h-8' }: P) { export function BrandMarkIcon({ className = 'w-8 h-8' }: P) {
// 品牌标志fill 型,与上面的描边图标集不同族,所以不包 Svg
// 用 currentColor 跟随文字色,放在 text-blue-600 的容器里就是品牌蓝。
//
// 几何来自 client/electron/src/icons/agentmail.svg —— 那份 SVG 是各端图标的
// 唯一源favicon / Electron / HarmonyOS 都由它生成,见同目录 generate.py
// 改一边就要重跑生成脚本,否则应用内标志与各端图标会不一致。
return ( return (
<Svg className={className}> <svg className={className} viewBox="0 0 24 24" fill="currentColor" role="img" aria-label="AgentMail">
<path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4z" /> <path d="M12 1.5a1.25 1.25 0 0 1 1.25 1.25c0 .5-.29.93-.71 1.13V5h1.71A2.75 2.75 0 0 1 17 7.75V8h2.5A2.5 2.5 0 0 1 22 10.5v9a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 19.5v-9A2.5 2.5 0 0 1 4.5 8H7v-.25A2.75 2.75 0 0 1 9.75 5h1.71V3.88c-.42-.2-.71-.63-.71-1.13A1.25 1.25 0 0 1 12 1.5m-2.25 5A1.25 1.25 0 0 0 8.5 7.75V8h7v-.25a1.25 1.25 0 0 0-1.25-1.25zM4.5 9.5a1 1 0 0 0-1 1v.34l8.5 4.72l8.5-4.72V10.5a1 1 0 0 0-1-1zm-1 3.05v6.95a1 1 0 0 0 1 1h15a1 1 0 0 0 1-1v-6.95l-8.5 4.72z" />
<path d="M6 8h4" /> <circle cx="9" cy="11.6" r=".9" />
<path d="M12 19V5" /> <circle cx="15" cy="11.6" r=".9" />
</Svg> </svg>
); );
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" role="img" aria-label="AgentMail"><title>AgentMail</title><path fill="currentColor" d="M12 1.5a1.25 1.25 0 0 1 1.25 1.25c0 .5-.29.93-.71 1.13V5h1.71A2.75 2.75 0 0 1 17 7.75V8h2.5A2.5 2.5 0 0 1 22 10.5v9a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 19.5v-9A2.5 2.5 0 0 1 4.5 8H7v-.25A2.75 2.75 0 0 1 9.75 5h1.71V3.88c-.42-.2-.71-.63-.71-1.13A1.25 1.25 0 0 1 12 1.5m-2.25 5A1.25 1.25 0 0 0 8.5 7.75V8h7v-.25a1.25 1.25 0 0 0-1.25-1.25zM4.5 9.5a1 1 0 0 0-1 1v.34l8.5 4.72l8.5-4.72V10.5a1 1 0 0 0-1-1zm-1 3.05v6.95a1 1 0 0 0 1 1h15a1 1 0 0 0 1-1v-6.95l-8.5 4.72z"/><circle cx="9" cy="11.6" r=".9" fill="currentColor"/><circle cx="15" cy="11.6" r=".9" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 756 B

View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""从 agentmail.svg 生成各端图标产物。
cd client/electron/src/icons && python3 generate.py
依赖 cairosvg 与 Pillow本机已装无 rsvg-convert / ImageMagick
为什么要脚本而不是手工导出PNG/ICO 是二进制,换一次配色就要重出十几个尺寸,
手工做必然出现「Web 是旧的、Harmony 是新的」这类不一致,而且没人能复核。
这里只认一个源agentmail.svg所有变体都由它推导。
生成物(相对本目录):
icon.png 512 应用图标(品牌底 + 白色字形)→ electron-builder Linux
icon.ico 16..256 → electron-builder Windows
tray-icon.png 32 托盘(透明底 + 品牌字形)
16x16.png … 512x512.png electron-builder 的 Linux icons 目录约定
../public/assets/agentmail.svg 前端 faviconSVG
../public/assets/favicon.ico favicon 兜底
../public/assets/apple-touch-icon.png 180
../../harmony/entry/src/main/resources/base/media/{startIcon,foreground,background}.png
../../harmony/AppScope/resources/base/media/{foreground,background}.png
"""
from __future__ import annotations
import io
import re
import sys
from pathlib import Path
import cairosvg
from PIL import Image
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[3] # client/electron/src/icons -> repo root
BRAND = "#2563eb" # 与门户 Dashy 主题的主色一致
WHITE = "#ffffff"
SRC = HERE / "agentmail.svg"
# 源文件里字形用的是 currentColor替换成实际颜色即可复用同一份几何。
COLOR_TOKEN = re.compile(r"currentColor")
def inner_markup(svg: str) -> str:
"""取出 <svg> 内的绘制内容(不含外层标签)。"""
body = svg.split(">", 1)[1].rsplit("</svg>", 1)[0]
# 去掉 title避免每个变体都带一份无障碍文本
return re.sub(r"<title>.*?</title>", "", body, flags=re.S)
def glyph_only(inner: str, color: str, *, inset: float = 0) -> str:
"""透明底、单色字形;可留安全边距给系统图标遮罩。"""
scale = 1 - 2 * inset
return (
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
f'<g fill="{color}" transform="translate({24 * inset},{24 * inset}) scale({scale})">'
f"{COLOR_TOKEN.sub(color, inner)}</g></svg>"
)
def on_brand_tile(inner: str, glyph: str, *, inset: float, radius: float) -> str:
"""品牌色圆角底 + 居中字形。
inset 是字形四周留白占边长的比例:桌面应用图标与 Harmony 的 layered
foreground 都要求「字形不顶边」,否则会被系统的圆角/遮罩裁掉。
"""
scale = 1 - 2 * inset
return (
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
f'<rect x="0" y="0" width="24" height="24" rx="{radius}" fill="{BRAND}"/>'
f'<g fill="{glyph}" transform="translate({24 * inset},{24 * inset}) scale({scale})">'
f"{COLOR_TOKEN.sub(glyph, inner)}</g></svg>"
)
def rasterize(svg: str, size: int) -> Image.Image:
png = cairosvg.svg2png(
bytestring=svg.encode(), output_width=size, output_height=size
)
if not isinstance(png, bytes):
raise RuntimeError("cairosvg 未返回 PNG 字节")
return Image.open(io.BytesIO(png)).convert("RGBA")
def write_png(img: Image.Image, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
img.save(path, format="PNG", optimize=True)
print(f" {path.relative_to(REPO)} {img.width}x{img.height}")
def write_ico(img: Image.Image, path: Path, sizes: list[int]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
img.save(path, format="ICO", sizes=[(s, s) for s in sizes])
print(f" {path.relative_to(REPO)} ico {sizes}")
def main() -> int:
if not SRC.exists():
print(f"缺少源文件 {SRC}", file=sys.stderr)
return 1
inner = inner_markup(SRC.read_text(encoding="utf-8"))
glyph_brand = glyph_only(inner, BRAND)
glyph_white = glyph_only(inner, WHITE, inset=0.12)
# 应用图标:白字形 + 品牌圆角底。圆角 18% 接近各平台图标的观感,
# 且留 12% 安全边距 —— Windows/Linux 会把图标压进更小的方块里显示。
app_svg = on_brand_tile(inner, WHITE, inset=0.12, radius=4.3)
app = rasterize(app_svg, 512)
print("Electron / electron-builder")
write_png(app, HERE / "icon.png")
write_ico(app, HERE / "icon.ico", [16, 24, 32, 48, 64, 128, 256])
for size in (16, 32, 48, 64, 128, 256, 512):
write_png(rasterize(app_svg, size), HERE / f"{size}x{size}.png")
# 托盘:透明底,面板底色未知,用品牌色字形;不做白色(浅色面板会消失)
write_png(rasterize(glyph_brand, 32), HERE / "tray-icon.png")
print("前端 faviconGateway 只服务 /assets/*,所以放在 public/assets 下)")
web = REPO / "client/electron/public/assets"
web.mkdir(parents=True, exist_ok=True)
(web / "agentmail.svg").write_text(
glyph_only(inner, BRAND) + "\n", encoding="utf-8"
)
print(f" {web.relative_to(REPO)}/agentmail.svg")
write_ico(app, web / "favicon.ico", [16, 32, 48])
write_png(rasterize(app_svg, 180), web / "apple-touch-icon.png")
print("HarmonyOS")
harmony_entry = REPO / "client/harmony/entry/src/main/resources/base/media"
harmony_app = REPO / "client/harmony/AppScope/resources/base/media"
# 启动图只在 entry 资源中引用。完整应用图标在浅/深启动页底色上都清楚。
write_png(rasterize(app_svg, 512), harmony_entry / "startIcon.png")
for base in (harmony_entry, harmony_app):
# 分层图标:背景整块品牌色,前景白色字形并留出安全区。
write_png(
rasterize(on_brand_tile(inner, BRAND, inset=0, radius=0), 1024),
base / "background.png",
)
write_png(rasterize(glyph_white, 1024), base / "foreground.png")
return 0
if __name__ == "__main__":
raise SystemExit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 B

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 B

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 B

After

Width:  |  Height:  |  Size: 14 KiB