# 唯一源
`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 排除)
115 lines
4.4 KiB
TypeScript
115 lines
4.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import { BrandMarkIcon, SpinnerIcon } from './icons';
|
||
|
||
export default function LoginPage() {
|
||
const login = useAuthStore(s => s.login);
|
||
const error = useAuthStore(s => s.error);
|
||
const retryAfter = useAuthStore(s => s.retryAfter);
|
||
const submitting = useAuthStore(s => s.submitting);
|
||
const clearError = useAuthStore(s => s.clearError);
|
||
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [countdown, setCountdown] = useState(0);
|
||
const userRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
userRef.current?.focus();
|
||
}, []);
|
||
|
||
// 被限速时倒计时
|
||
useEffect(() => {
|
||
if (!retryAfter) return;
|
||
setCountdown(retryAfter);
|
||
const t = setInterval(() => {
|
||
setCountdown(c => {
|
||
if (c <= 1) {
|
||
clearInterval(t);
|
||
clearError();
|
||
return 0;
|
||
}
|
||
return c - 1;
|
||
});
|
||
}, 1000);
|
||
return () => clearInterval(t);
|
||
}, [retryAfter]);
|
||
|
||
const locked = countdown > 0;
|
||
const canSubmit = username.trim() !== '' && password !== '' && !submitting && !locked;
|
||
|
||
const submit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!canSubmit) return;
|
||
const ok = await login(username, password);
|
||
if (!ok) setPassword('');
|
||
};
|
||
|
||
// 卡片高约 371px,比横屏手机(或软键盘弹出后)的可视高度还高。
|
||
//
|
||
// 居中用卡片自己的 `my-auto` 而**不是**容器的 `items-center`:后者在内容超高时
|
||
// 会让卡片上下同时溢出,而溢出到顶部那段滚不到(scrollTop 最小是 0)——
|
||
// 实测 568x280 下「登录」按钮完全在视口外,光加 overflow-y-auto 也够不着。
|
||
// auto margin 在空间不足时自动退化为 0,于是矮屏变成正常的顶对齐可滚布局。
|
||
return (
|
||
<div className="h-full overflow-y-auto flex justify-center bg-slate-100">
|
||
<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">
|
||
<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>
|
||
</div>
|
||
|
||
<form onSubmit={submit} className="space-y-3">
|
||
<div>
|
||
<label className="block text-[11px] font-medium text-gray-500 mb-1">用户名</label>
|
||
<input
|
||
ref={userRef}
|
||
value={username}
|
||
onChange={e => {
|
||
setUsername(e.target.value);
|
||
if (error) clearError();
|
||
}}
|
||
autoComplete="username"
|
||
spellCheck={false}
|
||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-medium text-gray-500 mb-1">密码</label>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={e => {
|
||
setPassword(e.target.value);
|
||
if (error) clearError();
|
||
}}
|
||
autoComplete="current-password"
|
||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||
{error}
|
||
{locked && `(${countdown} 秒后可重试)`}
|
||
</p>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={!canSubmit}
|
||
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{submitting && <SpinnerIcon className="w-3.5 h-3.5" />}
|
||
{submitting ? '登录中' : locked ? `已锁定 ${countdown}s` : '登录'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|