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 排除)
8
.gitignore
vendored
@ -53,6 +53,14 @@ attachments/
|
||||
.pi-glla/
|
||||
.codegraph
|
||||
|
||||
# ---- Python 工具缓存 ----
|
||||
#
|
||||
# client/electron/src/icons/generate.py 是图标生成脚本(cairosvg + Pillow)。
|
||||
# 静态检查器跑 ruff 时会在同目录落 .ruff_cache/,不是项目内容。
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# ---- 编辑器与系统 ----
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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>
|
||||
<script>
|
||||
/*
|
||||
@ -18,15 +31,15 @@
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
var p = localStorage.getItem('agentmail.theme') || 'system';
|
||||
var p = localStorage.getItem("agentmail.theme") || "system";
|
||||
var dark =
|
||||
p === 'dark' ||
|
||||
(p === 'system' &&
|
||||
p === "dark" ||
|
||||
(p === "system" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.style.colorScheme = "dark";
|
||||
}
|
||||
} catch (e) {
|
||||
/* localStorage 不可用(隐私模式)时退回浅色,不阻塞渲染 */
|
||||
|
||||
@ -56,11 +56,13 @@
|
||||
"output": "release"
|
||||
},
|
||||
"win": {
|
||||
"icon": "src/icons/icon.ico",
|
||||
"target": [
|
||||
{ "target": "nsis", "arch": ["x64"] }
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "src/icons",
|
||||
"target": [
|
||||
{ "target": "AppImage", "arch": ["x64"] },
|
||||
{ "target": "deb", "arch": ["x64"] }
|
||||
|
||||
1
client/electron/public/assets/agentmail.svg
Normal 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 |
BIN
client/electron/public/assets/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
client/electron/public/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { MailboxIcon, SpinnerIcon } from './icons';
|
||||
import { BrandMarkIcon, SpinnerIcon } from './icons';
|
||||
|
||||
export default function LoginPage() {
|
||||
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="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">
|
||||
<MailboxIcon className="w-6 h-6" />
|
||||
<BrandMarkIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="mt-3 text-base font-semibold text-gray-900">AgentMail</h1>
|
||||
<p className="mt-1 text-xs text-gray-500">邮件驱动的多智能体协作平台</p>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { MailboxIcon, SpinnerIcon } from './icons';
|
||||
import { BrandMarkIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/** 首次初始化向导:系统无任何用户时展示,创建首个管理员 */
|
||||
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="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">
|
||||
<MailboxIcon className="w-6 h-6" />
|
||||
<BrandMarkIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="mt-3 text-base font-semibold text-gray-900">初始化系统</h1>
|
||||
<p className="mt-1 text-xs text-gray-500 text-center">
|
||||
|
||||
@ -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 (
|
||||
<Svg className={className}>
|
||||
<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="M6 8h4" />
|
||||
<path d="M12 19V5" />
|
||||
</Svg>
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" role="img" aria-label="AgentMail">
|
||||
<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" />
|
||||
<circle cx="9" cy="11.6" r=".9" />
|
||||
<circle cx="15" cy="11.6" r=".9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
BIN
client/electron/src/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
client/electron/src/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 478 B |
BIN
client/electron/src/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
client/electron/src/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1022 B |
BIN
client/electron/src/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
client/electron/src/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
client/electron/src/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
1
client/electron/src/icons/agentmail.svg
Normal 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 |
147
client/electron/src/icons/generate.py
Normal 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 前端 favicon(SVG)
|
||||
../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("前端 favicon(Gateway 只服务 /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())
|
||||
BIN
client/electron/src/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
client/electron/src/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
client/electron/src/icons/tray-icon.png
Normal file
|
After Width: | Height: | Size: 1003 B |
|
Before Width: | Height: | Size: 65 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 65 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 65 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 65 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 65 B After Width: | Height: | Size: 14 KiB |