chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
248
client/electron/src/components/AccountPage.tsx
Normal file
248
client/electron/src/components/AccountPage.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { LockIcon, LogoutIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
import ThemePicker from './ThemePicker';
|
||||
|
||||
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
|
||||
export default function AccountPage() {
|
||||
const user = useAuthStore(s => s.user);
|
||||
const logout = useAuthStore(s => s.logout);
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 密钥面板状态
|
||||
const [keys, setKeys] = useState<api.UserKey[]>([]);
|
||||
const [keyBusy, setKeyBusy] = useState(false);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
|
||||
const loadKeys = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.listMyKeys();
|
||||
setKeys(r.keys);
|
||||
setKeyError(null);
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
const createKey = async (payload: api.CreateKeyPayload) => {
|
||||
setKeyBusy(true);
|
||||
setKeyError(null);
|
||||
try {
|
||||
const r = await api.createMyKey(payload);
|
||||
// 全文只在创建响应里出现一次,必须当场展示
|
||||
setNewToken(r.key.key_token ?? null);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setKeyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.deleteMyKey(id);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const mismatch = confirmPw !== '' && newPw !== confirmPw;
|
||||
const canSubmit = oldPw.length > 0 && newPw.length >= 8 && !mismatch && !busy;
|
||||
|
||||
const changePw = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.changePassword(oldPw, newPw);
|
||||
setMsg('密码已修改,请重新登录');
|
||||
setOldPw('');
|
||||
setNewPw('');
|
||||
setConfirmPw('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="shrink-0 px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
|
||||
</div>
|
||||
|
||||
{/* 滚动容器。
|
||||
缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出)
|
||||
比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,
|
||||
没有任何办法滚到。实测 390px 下内容需 860px、容器只有 795px;
|
||||
1280x800 的桌面上同样看不到最后的「退出登录」。 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg px-4 md:px-6 py-6 space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">基本资料</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row label="用户名" value={user.username} mono />
|
||||
<Row label="显示名" value={user.display_name} />
|
||||
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
|
||||
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
|
||||
<Row label="创建时间" value={user.created_at || '-'} />
|
||||
<Row label="最后登录" value={user.last_login || '从未登录'} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 权限边界 */}
|
||||
{user.role !== 'admin' && (
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">权限范围</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row
|
||||
label="可调用 Agent"
|
||||
value={
|
||||
user.allowed_agents.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_agents.join(', ')
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="可访问目录"
|
||||
value={
|
||||
user.allowed_paths.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_paths.join(', ')
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 修改密码 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
|
||||
<LockIcon className="w-3.5 h-3.5" />
|
||||
修改密码
|
||||
</h3>
|
||||
<form onSubmit={changePw} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPw}
|
||||
onChange={e => setOldPw(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 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">新密码(至少 8 位)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={e => setNewPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 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={confirmPw}
|
||||
onChange={e => setConfirmPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次密码不一致</p>}
|
||||
</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}
|
||||
</p>
|
||||
)}
|
||||
{msg && (
|
||||
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{busy ? '保存中' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* 客户端连接密钥 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<KeyPanel
|
||||
variant="user"
|
||||
keys={keys}
|
||||
loading={keyBusy}
|
||||
error={keyError}
|
||||
newToken={newToken}
|
||||
onCreate={createKey}
|
||||
onDelete={deleteKey}
|
||||
onDismissToken={() => setNewToken(null)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好,
|
||||
与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<ThemePicker />
|
||||
</section>
|
||||
|
||||
{/* 退出登录。
|
||||
放在这里而不是导航里:它是一个低频且不可逆的动作,
|
||||
与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口:
|
||||
抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">登录状态</h3>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="tap inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors"
|
||||
>
|
||||
<LogoutIcon className="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-3">
|
||||
<dt className="w-20 shrink-0 text-xs text-gray-400">{label}</dt>
|
||||
<dd className={`text-sm text-gray-900 break-all ${mono ? 'font-mono' : ''}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
client/electron/src/components/AddressInput.tsx
Normal file
265
client/electron/src/components/AddressInput.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { SessionCandidate } from '../types';
|
||||
|
||||
/**
|
||||
* 三段式地址输入:name -> @path -> .session
|
||||
* 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。
|
||||
* 值本身始终是完整字符串 name@path.session。
|
||||
*
|
||||
* session 段的候选带标题与来源标记:一个工作区下可能有十几条会话,
|
||||
* 光看 brisk-harbor / witty-planet 这类随机短名分不出哪条在谈什么。
|
||||
*/
|
||||
export default function AddressInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowMultiple = false,
|
||||
autoFocus = false
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
/** 抄送场景:允许逗号分隔多个地址,补全只作用于最后一段 */
|
||||
allowMultiple?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<string[]>([]);
|
||||
// session 段的富候选,与 items 同序。其他段为空数组。
|
||||
const [meta, setMeta] = useState<SessionCandidate[]>([]);
|
||||
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
|
||||
const [active, setActive] = useState(0);
|
||||
const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 });
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当前正在编辑的那一段(多地址时取最后一段)
|
||||
const { head, editing } = useMemo(() => {
|
||||
if (!allowMultiple) return { head: '', editing: value };
|
||||
const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';'));
|
||||
if (idx < 0) return { head: '', editing: value };
|
||||
return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() };
|
||||
}, [value, allowMultiple]);
|
||||
|
||||
// 把编辑段拆成 name / path / session 三部分
|
||||
const parts = useMemo(() => parseParts(editing), [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
// 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session
|
||||
const res = parts.hasDot
|
||||
? await api.suggestAddress(parts.name, parts.path)
|
||||
: parts.hasAt
|
||||
? await api.suggestAddress(parts.name)
|
||||
: await api.suggestAddress();
|
||||
if (cancelled) return;
|
||||
|
||||
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
|
||||
const lower = frag.toLowerCase();
|
||||
const all = res.suggestions || [];
|
||||
const cands = res.candidates || [];
|
||||
|
||||
// 过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的,
|
||||
// 分别过滤两个数组会让标题错位到别的别名上。
|
||||
const keep: number[] = [];
|
||||
all.forEach((s, i) => {
|
||||
const c = cands[i];
|
||||
// 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名
|
||||
const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase();
|
||||
if (hay.includes(lower)) keep.push(i);
|
||||
});
|
||||
|
||||
setKind(res.kind);
|
||||
setItems(keep.map(i => all[i]));
|
||||
setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []);
|
||||
setActive(0);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setItems([]);
|
||||
setMeta([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
const t = setTimeout(run, 120);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [parts.name, parts.path, parts.session, parts.hasAt, parts.hasDot]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, []);
|
||||
|
||||
// 补全菜单根据 Visual Viewport 剩余空间向上翻转;软键盘出现后也实时重算。
|
||||
useEffect(() => {
|
||||
if (!open || items.length === 0) return;
|
||||
const update = () => {
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
const rect = box.getBoundingClientRect();
|
||||
const viewport = window.visualViewport;
|
||||
const top = viewport?.offsetTop ?? 0;
|
||||
const bottom = top + (viewport?.height ?? window.innerHeight);
|
||||
const above = Math.max(0, rect.top - top - 8);
|
||||
const below = Math.max(0, bottom - rect.bottom - 8);
|
||||
const flip = below < Math.min(240, above) && above > below;
|
||||
setMenuLayout({ flip, maxHeight: Math.max(96, Math.min(288, flip ? above : below)) });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update, { passive: true });
|
||||
window.addEventListener('scroll', update, { passive: true, capture: true });
|
||||
window.visualViewport?.addEventListener('resize', update, { passive: true });
|
||||
window.visualViewport?.addEventListener('scroll', update, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('scroll', update, { capture: true });
|
||||
window.visualViewport?.removeEventListener('resize', update);
|
||||
window.visualViewport?.removeEventListener('scroll', update);
|
||||
};
|
||||
}, [open, items.length]);
|
||||
|
||||
/** 选中一个候选后拼回完整地址 */
|
||||
const apply = (choice: string) => {
|
||||
let next: string;
|
||||
if (kind === 'name') {
|
||||
next = `${choice}@`;
|
||||
} else if (kind === 'path') {
|
||||
next = `${parts.name}@${choice}.`;
|
||||
} else {
|
||||
next = `${parts.name}@${parts.path}.${choice}`;
|
||||
}
|
||||
onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next);
|
||||
// name/path 选完仍停留在补全态,继续下一段
|
||||
setOpen(kind !== 'session');
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i + 1) % items.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i - 1 + items.length) % items.length);
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
apply(items[active]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hint =
|
||||
kind === 'name'
|
||||
? 'Agent 名'
|
||||
: kind === 'path'
|
||||
? '工作区路径'
|
||||
: '会话别名(new 为新建)';
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative">
|
||||
<input
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
onChange={e => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
|
||||
{open && items.length > 0 && (
|
||||
<div
|
||||
className={`absolute z-20 w-full overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg ${
|
||||
menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||
}`}
|
||||
style={{ maxHeight: menuLayout.maxHeight }}
|
||||
>
|
||||
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
|
||||
{hint}
|
||||
</div>
|
||||
{items.map((s, i) => {
|
||||
const c = meta[i];
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
apply(s);
|
||||
}}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`w-full text-left px-2.5 py-1.5 ${
|
||||
i === active ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-sm font-mono truncate ${
|
||||
i === active ? 'text-blue-700' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{/* 平台侧会话本侧还没有邮件线索:标出来,让人知道这一封是「接入」
|
||||
一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
|
||||
{c?.source === 'platform' && (
|
||||
<span
|
||||
className="shrink-0 px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]"
|
||||
title="平台侧已有的会话,本站还没有对应的邮件往来"
|
||||
>
|
||||
平台
|
||||
</span>
|
||||
)}
|
||||
{c?.source === 'new' && (
|
||||
<span className="shrink-0 text-[10px] text-gray-400 font-sans">新建会话</span>
|
||||
)}
|
||||
{(c?.unread ?? 0) > 0 && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-[9px]">
|
||||
{c!.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{c?.title && c.source !== 'new' && (
|
||||
<p className="text-[10px] text-gray-400 truncate mt-0.5">{c.title}</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */
|
||||
function parseParts(s: string) {
|
||||
const at = s.indexOf('@');
|
||||
if (at < 0) {
|
||||
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
|
||||
}
|
||||
const name = s.slice(0, at);
|
||||
const rest = s.slice(at + 1);
|
||||
const dot = rest.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return { name, path: rest, session: '', hasAt: true, hasDot: false };
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: rest.slice(0, dot),
|
||||
session: rest.slice(dot + 1),
|
||||
hasAt: true,
|
||||
hasDot: true
|
||||
};
|
||||
}
|
||||
451
client/electron/src/components/AdminUsersPage.tsx
Normal file
451
client/electron/src/components/AdminUsersPage.tsx
Normal file
@ -0,0 +1,451 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { AdminScopes, User } from '../types';
|
||||
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
import QuotaPanel from './QuotaPanel';
|
||||
import ModelScopePanel from './ModelScopePanel';
|
||||
|
||||
type Tab = 'users' | 'keys' | 'quotas' | 'models';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [tab, setTab] = useState<Tab>('users');
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [scopes, setScopes] = useState<AdminScopes>({ agents: [], paths: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
// Agent 密钥面板
|
||||
const [keys, setKeys] = useState<api.AgentKey[]>([]);
|
||||
const [keyBusy, setKeyBusy] = useState(false);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [u, s] = await Promise.all([api.adminListUsers(), api.adminListScopes()]);
|
||||
setUsers(u.users || []);
|
||||
setScopes(s);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const loadKeys = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.adminListAgentKeys();
|
||||
setKeys(r.keys);
|
||||
setKeyError(null);
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'keys') loadKeys();
|
||||
}, [tab, loadKeys]);
|
||||
|
||||
const createKey = async (payload: api.CreateKeyPayload) => {
|
||||
setKeyBusy(true);
|
||||
setKeyError(null);
|
||||
try {
|
||||
const r = await api.adminCreateAgentKey(payload);
|
||||
// 登记客户端已有密钥时对方已经持有全文,无需再弹一次
|
||||
setNewToken(payload.key_token ? null : r.key.key_token ?? null);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setKeyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.adminDeleteAgentKey(id);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const bindKey = async (id: string, agentName: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.adminBindAgentKey(id, agentName);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const flash = (msg: string) => {
|
||||
setNotice(msg);
|
||||
setTimeout(() => setNotice(null), 2500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-1 flex-wrap">
|
||||
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
用户管理
|
||||
<span className="text-xs text-gray-400">{users.length}</span>
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'keys'} onClick={() => setTab('keys')}>
|
||||
<KeyIcon className="w-4 h-4" />
|
||||
Agent 密钥
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
|
||||
<BotIcon className="w-4 h-4" />
|
||||
Agent 管理
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'models'} onClick={() => setTab('models')}>
|
||||
<CpuIcon className="w-4 h-4" />
|
||||
模型范围
|
||||
</TabButton>
|
||||
<div className="flex-1" />
|
||||
{notice && <span className="text-xs text-green-600">{notice}</span>}
|
||||
{tab === 'users' && (
|
||||
<button onClick={() => setCreating(v => !v)} className="tap px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700">
|
||||
{creating ? '收起' : '新建用户'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
|
||||
|
||||
{tab === 'models' ? (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<ModelScopeTab />
|
||||
</div>
|
||||
) : tab === 'quotas' ? (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<QuotaPanel />
|
||||
</div>
|
||||
) : tab === 'keys' ? (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<KeyPanel
|
||||
variant="agent"
|
||||
keys={keys}
|
||||
loading={keyBusy}
|
||||
error={keyError}
|
||||
newToken={newToken}
|
||||
onCreate={createKey}
|
||||
onDelete={deleteKey}
|
||||
onBind={bindKey}
|
||||
onDismissToken={() => setNewToken(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{creating && <CreateUserForm scopes={scopes} onDone={() => { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-2">
|
||||
{users.map(u => (
|
||||
<UserCard key={u.user_id} user={u} scopes={scopes}
|
||||
expanded={editing === u.user_id}
|
||||
onToggle={() => setEditing(editing === u.user_id ? null : u.user_id)}
|
||||
onSaved={flash} onReload={load} setError={setError} />
|
||||
))}
|
||||
{loading && users.length === 0 && <p className="text-xs text-gray-400 text-center py-6">加载中</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }: {
|
||||
active: boolean; onClick: () => void; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`tap flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
|
||||
active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── UserCard ── */
|
||||
|
||||
function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setError }: {
|
||||
user: User; scopes: AdminScopes; expanded: boolean; onToggle: () => void;
|
||||
onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200">
|
||||
<div className="px-4 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<button onClick={onToggle} className="tap flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
|
||||
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
|
||||
<span className="tap text-[11px] text-gray-500">{user.display_name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{user.role === 'admin' ? '管理员' : '用户'}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
|
||||
{user.status === 'active' ? '启用' : '禁用'}
|
||||
</span>
|
||||
{user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && (
|
||||
<span className="text-[10px] text-gray-400">受限</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-gray-400">{user.last_login || '从未登录'}</span>
|
||||
</div>
|
||||
{expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── UserEditor ── */
|
||||
|
||||
function UserEditor({ user, scopes, onSaved, onReload, setError }: {
|
||||
user: User; scopes: AdminScopes; onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState(user.display_name);
|
||||
const [role, setRole] = useState<'admin' | 'user'>(user.role as 'admin' | 'user');
|
||||
const [agents, setAgents] = useState<string[]>(user.allowed_agents);
|
||||
const [paths, setPaths] = useState<string[]>(user.allowed_paths);
|
||||
const [pw, setPw] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggleAgent = (a: string) => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a]);
|
||||
const togglePath = (p: string) => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.adminUpdateUser(user.user_id, { display_name: displayName, role, allowed_agents: agents, allowed_paths: paths });
|
||||
onSaved('用户已更新'); await onReload();
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const disableUser = async () => {
|
||||
try { await api.adminDisableUser(user.user_id); onSaved('用户已禁用'); await onReload(); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
};
|
||||
|
||||
const enableUser = async () => {
|
||||
try { await api.adminUpdateUser(user.user_id, { status: 'active' }); onSaved('用户已启用'); await onReload(); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
};
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (pw.length < 8) return;
|
||||
setBusy(true);
|
||||
try { await api.adminResetPassword(user.user_id, pw); onSaved('密码已重置'); setPw(''); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">显示名</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 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>
|
||||
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">状态</label>
|
||||
{user.status === 'active' ? (
|
||||
<button onClick={disableUser} className="px-3 py-1.5 text-xs rounded-md border border-red-300 text-red-600 hover:bg-red-50 w-full">禁用</button>
|
||||
) : (
|
||||
<button onClick={enableUser} className="px-3 py-1.5 text-xs rounded-md border border-green-300 text-green-700 hover:bg-green-50 w-full">启用</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.role !== 'admin' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
|
||||
可调用 Agent {agents.length > 0 && <span className="text-gray-400">({agents.length} 项)</span>}
|
||||
<span className="ml-2 font-normal text-gray-400">未勾选 = 不限</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scopes.agents.map(a => (
|
||||
<button key={a} onClick={() => toggleAgent(a)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
agents.includes(a) ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{a}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
|
||||
可访问目录 {paths.length > 0 && <span className="text-gray-400">({paths.length} 项)</span>}
|
||||
<span className="ml-2 font-normal text-gray-400">未勾选 = 不限;按目录前缀匹配</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scopes.paths.map(p => (
|
||||
<button key={p} onClick={() => togglePath(p)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
paths.includes(p) ? 'bg-green-50 border-green-300 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<button onClick={save} disabled={busy} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
|
||||
{busy ? '保存中' : '保存更改'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 ml-auto flex-wrap">
|
||||
<LockIcon className="w-3 h-3 text-gray-400" />
|
||||
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
|
||||
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
|
||||
className="tap inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-chrome-700 text-white hover:bg-chrome-800 disabled:opacity-40">
|
||||
<CheckIcon className="w-3 h-3" /> 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── CreateUserForm ── */
|
||||
|
||||
function CreateUserForm({ scopes, onDone, onError }: {
|
||||
scopes: AdminScopes; onDone: () => void; onError: (msg: string) => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
const [agents, setAgents] = useState<string[]>([]);
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ok = username.trim().length >= 2 && password.length >= 8 && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.adminCreateUser({
|
||||
username: username.trim().toLowerCase(), password, display_name: displayName.trim(), role: role,
|
||||
allowed_agents: role === 'admin' ? [] : agents, allowed_paths: role === 'admin' ? [] : paths,
|
||||
});
|
||||
setUsername(''); setDisplayName(''); setPassword(''); setRole('user'); setAgents([]); setPaths([]);
|
||||
onDone();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-6 mt-3 p-4 rounded-lg border border-gray-200 bg-gray-50 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Field label="用户名" hint="小写字母数字 . _ -">
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="alice" spellCheck={false}
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||
</Field>
|
||||
<Field label="显示名"><input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Alice"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /></Field>
|
||||
<Field label="初始密码" hint="至少 8 位"><input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /></Field>
|
||||
<Field label="角色">
|
||||
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{role !== 'admin' && (
|
||||
<>
|
||||
<ScopePick label="可调用 Agent" items={scopes.agents} selected={agents} onToggle={a => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a])} color="blue" />
|
||||
<ScopePick label="可访问目录" items={scopes.paths} selected={paths} onToggle={p => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])} color="green" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={submit} disabled={!ok} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40">
|
||||
{busy ? '创建中' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopePick({ label, items, selected, onToggle, color }: {
|
||||
label: string; items: string[]; selected: string[]; onToggle: (item: string) => void; color: 'blue' | 'green';
|
||||
}) {
|
||||
const active = color === 'blue' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-green-50 border-green-300 text-green-700';
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">
|
||||
{label} <span className="font-normal text-gray-400">未勾选 = 不限</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map(i => (
|
||||
<button key={i} onClick={() => onToggle(i)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
selected.includes(i) ? active : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{i}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5 mb-1">
|
||||
<label className="text-[11px] font-medium text-gray-500">{label}</label>
|
||||
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型范围页。
|
||||
*
|
||||
* Agent 列表复用 `/admin/quotas` —— 它返回的就是全部已注册 Agent。
|
||||
* 另开一个「列出 Agent」接口只会多一条做同一件事的路径。
|
||||
*/
|
||||
function ModelScopeTab() {
|
||||
const [agents, setAgents] = useState<api.AgentStats[]>([]);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.adminListAgentStats()
|
||||
.then(res => setAgents(res.quotas))
|
||||
.catch(e => setErr(e instanceof Error ? e.message : String(e)));
|
||||
}, []);
|
||||
|
||||
if (err) return <p className="text-xs text-red-600">{err}</p>;
|
||||
return <ModelScopePanel agents={agents} />;
|
||||
}
|
||||
171
client/electron/src/components/Attachments.tsx
Normal file
171
client/electron/src/components/Attachments.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { Attachment } from '../types';
|
||||
import { PaperclipIcon, DownloadIcon, FileIcon, CloseIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/** 已发出邮件的附件清单(只读,点击下载)。 */
|
||||
export function AttachmentList({ items }: { items: Attachment[] }) {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-gray-100 pt-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<PaperclipIcon className="w-3.5 h-3.5 text-gray-400" />
|
||||
<span className="text-[11px] font-medium text-gray-500">
|
||||
附件 {items.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{items.map(a => (
|
||||
<li key={a.attachment_id}>
|
||||
<a
|
||||
href={api.attachmentURL(a.attachment_id)}
|
||||
// download 让浏览器保存而非尝试渲染;服务端也已强制 octet-stream + attachment
|
||||
download={a.filename}
|
||||
className="group flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">
|
||||
{api.formatSize(a.size_bytes)}
|
||||
</span>
|
||||
<DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 待发送的附件:已上传到服务器、等着随邮件发出。 */
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写信时的附件选择器。
|
||||
*
|
||||
* 上传是独立一步:选中即上传,拿到 attachment_id 后暂存,发信时一并提交。
|
||||
* 之所以不等到点「发送」再传:大文件上传要时间,让用户在写正文时就完成上传体验更好,
|
||||
* 而且上传失败能立刻反馈而不是卡在发送那一刻。
|
||||
*/
|
||||
export function AttachmentPicker({
|
||||
items,
|
||||
onChange,
|
||||
disabled
|
||||
}: {
|
||||
items: PendingAttachment[];
|
||||
onChange: (next: PendingAttachment[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState<{ name: string; pct: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setError(null);
|
||||
|
||||
// 逐个上传而非并发:并发时进度条只能显示其中一个,且大文件同时传更容易触发体积限制
|
||||
const added: PendingAttachment[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
setUploading({ name: file.name, pct: 0 });
|
||||
try {
|
||||
const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct }));
|
||||
added.push({
|
||||
id: r.attachment.attachment_id,
|
||||
filename: r.attachment.filename,
|
||||
size: r.attachment.size_bytes
|
||||
});
|
||||
} catch (err) {
|
||||
setError(`${file.name}:${err instanceof Error ? err.message : String(err)}`);
|
||||
break; // 一个失败就停下,避免连续弹同类错误
|
||||
}
|
||||
}
|
||||
setUploading(null);
|
||||
if (added.length > 0) onChange([...items, ...added]);
|
||||
|
||||
// 清空 input,否则重复选同一个文件不会触发 change
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const remove = async (a: PendingAttachment) => {
|
||||
// 从服务器删掉未挂载的附件,不然它会占着磁盘等 24 小时 GC
|
||||
try {
|
||||
await api.deleteAttachment(a.id);
|
||||
} catch {
|
||||
/* 删不掉也只是留给 GC,不该阻塞用户移除操作 */
|
||||
}
|
||||
onChange(items.filter(x => x.id !== a.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={disabled || uploading !== null}
|
||||
className="tap shrink-0 inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||
添加附件
|
||||
</button>
|
||||
|
||||
{uploading && (
|
||||
<span className="min-w-0 flex-1 inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
<span className="truncate">{uploading.name}</span>
|
||||
<span className="shrink-0">{uploading.pct}%</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{items.length > 0 && !uploading && (
|
||||
<span className="text-[11px] text-gray-500 min-w-0 break-words">
|
||||
{items.length} 个附件 ·{' '}
|
||||
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-red-600 break-words">{error}</div>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{items.map(a => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 bg-gray-50"
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(a)}
|
||||
disabled={disabled}
|
||||
title="移除"
|
||||
className="tap shrink-0 inline-flex items-center justify-center text-gray-500 hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
client/electron/src/components/BackButton.tsx
Normal file
30
client/electron/src/components/BackButton.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import { ChevronLeftIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 窄屏返回按钮。
|
||||
*
|
||||
* 只在窄屏出现:宽屏是列表与详情并排,没有「返回」这个概念 ——
|
||||
* 放一个按钮在那里,点了什么也不会发生。
|
||||
*
|
||||
* 覆盖式布局下返回 = 让覆盖层滑出去(narrowPane 回到 list),
|
||||
* 而不是卸载详情组件:底层列表一直挂载着,滚动位置与选中态都还在。
|
||||
*/
|
||||
export default function BackButton({ label = '返回' }: { label?: string }) {
|
||||
const narrow = useIsNarrow();
|
||||
const showList = useUIStore(s => s.showList);
|
||||
|
||||
if (!narrow) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={showList}
|
||||
className="tap shrink-0 -ml-1 mr-1 inline-flex items-center gap-0.5 py-1 pr-1.5 pl-0.5 rounded text-gray-500 active:bg-gray-100"
|
||||
aria-label={label}
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
<span className="text-xs">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
694
client/electron/src/components/CalendarEventEditor.tsx
Normal file
694
client/electron/src/components/CalendarEventEditor.tsx
Normal file
@ -0,0 +1,694 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CalendarEventInput,
|
||||
CalendarAttachment,
|
||||
Recurrence,
|
||||
DeliveryMode
|
||||
} from '../types';
|
||||
import AddressInput from './AddressInput';
|
||||
import {
|
||||
toLocalInput,
|
||||
fromLocalInput,
|
||||
renderReminder,
|
||||
describeRemindBefore
|
||||
} from '../lib/calendar';
|
||||
import {
|
||||
upcomingOccurrences,
|
||||
formatSolarWithLunar,
|
||||
isLunarRecurrence,
|
||||
describeRecurrenceRule
|
||||
} from '../lib/lunar';
|
||||
import {
|
||||
CloseIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
BellIcon,
|
||||
RepeatIcon,
|
||||
PaperclipIcon,
|
||||
FileIcon,
|
||||
PlusIcon,
|
||||
UsersIcon,
|
||||
BotIcon
|
||||
} from './icons';
|
||||
|
||||
/**
|
||||
* 事件编辑器(新建 / 编辑共用)。
|
||||
*
|
||||
* 参照 Outlook 的编辑面板:时间与重复在上、收件方居中、提醒正文在下。
|
||||
* 提醒正文可编辑且带 `{title}` `{time}` `{description}` 变量 —— 预览实时渲染,
|
||||
* 因为「模板里写了什么」和「Agent 收到什么」不是一个东西,
|
||||
* 不给预览的话人只能发一次试试看。
|
||||
*/
|
||||
|
||||
/** 提前提醒的预设档位。手打分钟数容易写出档位外的值,但也允许。 */
|
||||
const PRESETS = [0, 5, 15, 30, 60, 120, 1440];
|
||||
|
||||
const DEFAULT_TEMPLATE = '日程提醒:{title}\n时间:{time}\n{description}';
|
||||
|
||||
/** 重复规则选项。农历单独一组:它们的公历日期每次都在漂移。 */
|
||||
const RECURRENCE_GROUPS: { label: string; items: { value: Recurrence; label: string }[] }[] = [
|
||||
{
|
||||
label: '公历',
|
||||
items: [
|
||||
{ value: 'none', label: '不重复' },
|
||||
{ value: 'daily', label: '每天' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'monthly', label: '每月(同一日)' },
|
||||
{ value: 'yearly', label: '每年(同月日)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '农历',
|
||||
items: [
|
||||
{ value: 'lunar_monthly', label: '每农历月(同一日,如每月十五)' },
|
||||
{ value: 'lunar_yearly', label: '每农历年(同月日,如农历生日)' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function CalendarEventEditor({
|
||||
event,
|
||||
initialTime,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
/** 有值 = 编辑,无值 = 新建 */
|
||||
event?: CalendarEvent | null;
|
||||
/** 新建时的预填时间(点空白格子建事件) */
|
||||
initialTime?: Date;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const editing = !!event;
|
||||
|
||||
const [title, setTitle] = useState(event?.title ?? '');
|
||||
const [description, setDescription] = useState(event?.description ?? '');
|
||||
const [reminderText, setReminderText] = useState(event?.reminder_text ?? '');
|
||||
|
||||
/**
|
||||
* 收件人列表。
|
||||
*
|
||||
* 初值兼容旧数据:recipients 为空时退回 to_address / agent_name ——
|
||||
* 与后端 EffectiveRecipients() 同一条兜底链。不做这个归一化的话,
|
||||
* 编辑一条老事件再保存会把它的收件人清空(列表是空的,保存就覆盖了)。
|
||||
*/
|
||||
const [recipients, setRecipients] = useState<string[]>(() => {
|
||||
if (event?.recipients?.length) return event.recipients;
|
||||
const legacy = (event?.to_address || event?.agent_name || '').trim();
|
||||
return legacy ? [legacy] : [];
|
||||
});
|
||||
const [draftAddr, setDraftAddr] = useState('');
|
||||
const [deliveryMode, setDeliveryMode] = useState<DeliveryMode>(
|
||||
event?.delivery_mode === 'together' ? 'together' : 'separate'
|
||||
);
|
||||
|
||||
const [eventTime, setEventTime] = useState(() => {
|
||||
if (event?.event_time) return toLocalInput(new Date(event.event_time));
|
||||
if (initialTime) return toLocalInput(initialTime);
|
||||
// 默认下一个整点:现在这一刻当默认值几乎总要改
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||
return toLocalInput(d);
|
||||
});
|
||||
const [remindBefore, setRemindBefore] = useState(event?.remind_before ?? 0);
|
||||
const [recurrence, setRecurrence] = useState<Recurrence>(event?.recurrence ?? 'none');
|
||||
const [recurrenceEnd, setRecurrenceEnd] = useState(
|
||||
event?.recurrence_end ? toLocalInput(new Date(event.recurrence_end)) : ''
|
||||
);
|
||||
const [status, setStatus] = useState<CalendarEvent['status']>(event?.status ?? 'active');
|
||||
|
||||
const [agents, setAgents] = useState<string[]>([]);
|
||||
const [atts, setAtts] = useState<CalendarAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Agent 列表用于快捷添加。拉不到不算错 —— 地址仍可手输(三段式补全独立工作)。
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAgents()
|
||||
.then(r => setAgents((r.agents ?? []).map(a => a.agent_name)))
|
||||
.catch(() => setAgents([]));
|
||||
}, []);
|
||||
|
||||
// 附件只在编辑既有事件时才有:新建时还没有 event_id 可挂
|
||||
useEffect(() => {
|
||||
if (!event?.event_id) return;
|
||||
api
|
||||
.listCalendarAttachments(event.event_id)
|
||||
.then(r => setAtts(r.attachments ?? []))
|
||||
.catch(() => setAtts([]));
|
||||
}, [event?.event_id]);
|
||||
|
||||
function addRecipient(addr: string) {
|
||||
const v = addr.trim();
|
||||
if (!v) return;
|
||||
// 去重:together 模式下同一个 Agent 既主收又抄送会收到两条 SSE,
|
||||
// 插件可能因此起两轮
|
||||
if (recipients.includes(v)) {
|
||||
setDraftAddr('');
|
||||
return;
|
||||
}
|
||||
setRecipients(prev => [...prev, v]);
|
||||
setDraftAddr('');
|
||||
}
|
||||
|
||||
function removeRecipient(addr: string) {
|
||||
setRecipients(prev => prev.filter(x => x !== addr));
|
||||
}
|
||||
|
||||
/** 上移一位。together 模式下第一个是主收件人,顺序有语义。 */
|
||||
function moveUp(i: number) {
|
||||
if (i <= 0) return;
|
||||
setRecipients(prev => {
|
||||
const next = [...prev];
|
||||
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const tpl = reminderText.trim() || DEFAULT_TEMPLATE;
|
||||
return renderReminder(tpl, {
|
||||
title: title || '(未填标题)',
|
||||
description,
|
||||
event_time: fromLocalInput(eventTime) || new Date().toISOString()
|
||||
});
|
||||
}, [reminderText, title, description, eventTime]);
|
||||
|
||||
/**
|
||||
* 接下来三次触发。
|
||||
*
|
||||
* 农历规则必须给这个预览:规则名(「每农历月廿二」)看不出公历日子,
|
||||
* 而公历日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。
|
||||
*/
|
||||
const upcoming = useMemo(() => {
|
||||
if (recurrence === 'none') return [];
|
||||
const iso = fromLocalInput(eventTime);
|
||||
if (!iso) return [];
|
||||
return upcomingOccurrences(recurrence, new Date(iso), 3);
|
||||
}, [recurrence, eventTime]);
|
||||
|
||||
async function addFiles(files: FileList | null) {
|
||||
if (!files?.length || !event?.event_id) return;
|
||||
setUploading(true);
|
||||
setErr('');
|
||||
try {
|
||||
// 逐个传而非并发:并发失败时分不清是哪个文件的问题
|
||||
for (const f of Array.from(files)) {
|
||||
const a = await api.uploadCalendarAttachment(event.event_id, f);
|
||||
setAtts(prev => [...prev, a]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function dropAttachment(id: string) {
|
||||
try {
|
||||
await api.deleteCalendarAttachment(id);
|
||||
setAtts(prev => prev.filter(a => a.attachment_id !== id));
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '删除附件失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 收件人为空时不能保存:事件永远发不出去,后端也会 400。
|
||||
// 在这里就禁用按钮比让人点了再看报错好。
|
||||
const canSave = title.trim() !== '' && eventTime !== '' && recipients.length > 0 && !saving;
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
setErr('');
|
||||
setSaving(true);
|
||||
const iso = fromLocalInput(eventTime);
|
||||
if (!iso) {
|
||||
setErr('事件时间无效');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
// recurrence 为 none 时清掉终止时间:留着它只会让后续编辑困惑
|
||||
const endIso = recurrence === 'none' || !recurrenceEnd ? null : fromLocalInput(recurrenceEnd);
|
||||
const payload: CalendarEventInput = {
|
||||
title: title.trim(),
|
||||
description,
|
||||
reminder_text: reminderText.trim(),
|
||||
recipients,
|
||||
delivery_mode: deliveryMode,
|
||||
// 旧字段保持与列表首项一致:第三方客户端(与旧版前端)只读 to_address
|
||||
to_address: recipients[0] ?? '',
|
||||
event_time: iso,
|
||||
remind_before: remindBefore,
|
||||
recurrence,
|
||||
recurrence_end: endIso,
|
||||
status
|
||||
};
|
||||
try {
|
||||
if (editing && event) await api.updateCalendarEvent(event.event_id, payload);
|
||||
else await api.createCalendarEvent(payload);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!event) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteCalendarEvent(event.event_id);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '删除失败');
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const unusedAgents = agents.filter(a => !recipients.includes(a));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-white">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
|
||||
<h2 className="text-base font-medium text-gray-900">
|
||||
{editing ? '编辑日程' : '新建日程'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-500"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">标题</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="例如:llmsproxy 发布评审"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">说明</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="可留空;会作为 {description} 变量填入提醒正文"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">时间</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={eventTime}
|
||||
onChange={e => setEventTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{fromLocalInput(eventTime) && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{formatSolarWithLunar(new Date(fromLocalInput(eventTime)))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
|
||||
<BellIcon className="w-3.5 h-3.5" />
|
||||
提醒
|
||||
</label>
|
||||
<select
|
||||
value={PRESETS.includes(remindBefore) ? String(remindBefore) : 'custom'}
|
||||
onChange={e => {
|
||||
if (e.target.value !== 'custom') setRemindBefore(Number(e.target.value));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{PRESETS.map(m => (
|
||||
<option key={m} value={m}>
|
||||
{describeRemindBefore(m)}
|
||||
</option>
|
||||
))}
|
||||
{!PRESETS.includes(remindBefore) && (
|
||||
<option value="custom">{describeRemindBefore(remindBefore)}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
|
||||
<RepeatIcon className="w-3.5 h-3.5" />
|
||||
重复
|
||||
</label>
|
||||
<select
|
||||
value={recurrence}
|
||||
onChange={e => setRecurrence(e.target.value as Recurrence)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{RECURRENCE_GROUPS.map(g => (
|
||||
<optgroup key={g.label} label={g.label}>
|
||||
{g.items.map(it => (
|
||||
<option key={it.value} value={it.value}>
|
||||
{it.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{recurrence !== 'none' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="px-2.5 py-2 bg-gray-50 border border-gray-200 rounded">
|
||||
<div className="text-xs text-gray-600 mb-1">
|
||||
{describeRecurrenceRule(
|
||||
recurrence,
|
||||
fromLocalInput(eventTime) ? new Date(fromLocalInput(eventTime)) : undefined
|
||||
)}
|
||||
{isLunarRecurrence(recurrence) && (
|
||||
<span className="ml-1 text-amber-700">· 按农历推进</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 接下来三次必须显示:农历规则的公历日期每次都在变,
|
||||
光看规则名分辨不出对不对,而错了要等一个月才发现 */}
|
||||
{upcoming.length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{upcoming.map((d, i) => (
|
||||
<li key={i} className="text-xs text-gray-700 tabular-nums">
|
||||
{formatSolarWithLunar(d)}{' '}
|
||||
<span className="text-gray-400">
|
||||
{String(d.getHours()).padStart(2, '0')}:
|
||||
{String(d.getMinutes()).padStart(2, '0')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-amber-700">
|
||||
算不出下一次 —— 这条规则在后续年份可能不存在(例如闰月)。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">重复至</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={recurrenceEnd}
|
||||
onChange={e => setRecurrenceEnd(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">留空 = 一直重复</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 收件人 ── */}
|
||||
<div className="pt-1 border-t border-gray-100">
|
||||
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1 mt-3">
|
||||
<UsersIcon className="w-3.5 h-3.5" />
|
||||
提醒发给谁({recipients.length})
|
||||
</label>
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<ul className="mb-2 space-y-1">
|
||||
{recipients.map((addr, i) => (
|
||||
<li
|
||||
key={addr}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-gray-50 border border-gray-200 rounded text-xs"
|
||||
>
|
||||
<BotIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 min-w-0 truncate text-gray-800 font-mono">{addr}</span>
|
||||
{/* together 模式下首个是主收件人,顺序有语义,因此要能调 */}
|
||||
{deliveryMode === 'together' && i === 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded shrink-0">
|
||||
主收件人
|
||||
</span>
|
||||
)}
|
||||
{deliveryMode === 'together' && i > 0 && (
|
||||
<button
|
||||
onClick={() => moveUp(i)}
|
||||
title="设为主收件人方向移动"
|
||||
className="px-1 rounded hover:bg-gray-200 text-gray-500 shrink-0"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeRecipient(addr)}
|
||||
className="p-0.5 rounded hover:bg-gray-200 text-gray-500 shrink-0"
|
||||
aria-label={`移除 ${addr}`}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<AddressInput
|
||||
value={draftAddr}
|
||||
onChange={setDraftAddr}
|
||||
placeholder="name@path.session(省略 .session = 默认会话)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addRecipient(draftAddr)}
|
||||
disabled={!draftAddr.trim()}
|
||||
className="px-2.5 py-2 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50 disabled:opacity-40 shrink-0 flex items-center gap-1"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{unusedAgents.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{unusedAgents.map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => addRecipient(a)}
|
||||
className="px-2 py-0.5 text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-200"
|
||||
>
|
||||
+ {a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipients.length > 1 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">怎么投递</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={deliveryMode === 'separate'}
|
||||
onChange={() => setDeliveryMode('separate')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-gray-800">分别发送</span>
|
||||
<span className="block text-gray-500">
|
||||
每人一封、落各自的会话,互相看不到 —— 适合让几个 Agent
|
||||
各自独立判断。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={deliveryMode === 'together'}
|
||||
onChange={() => setDeliveryMode('together')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-gray-800">一起发送</span>
|
||||
<span className="block text-gray-500">
|
||||
首个是主收件人、其余抄送,共享同一条线索,能看到彼此的回复 ——
|
||||
适合有主次的协作。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 提醒正文 ── */}
|
||||
<div className="pt-1 border-t border-gray-100">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1 mt-3">提醒正文</label>
|
||||
<textarea
|
||||
value={reminderText}
|
||||
onChange={e => setReminderText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={DEFAULT_TEMPLATE}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm font-mono resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{['{title}', '{time}', '{description}'].map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setReminderText(t => t + v)}
|
||||
className="px-2 py-0.5 text-xs font-mono bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-200"
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">Agent 会收到</div>
|
||||
<pre className="px-3 py-2 bg-gray-50 border border-gray-200 rounded text-xs text-gray-800 whitespace-pre-wrap break-words">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
|
||||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||
附件(随提醒邮件一起发出)
|
||||
</label>
|
||||
{atts.length > 0 && (
|
||||
<ul className="mb-2 space-y-1">
|
||||
{atts.map(a => (
|
||||
<li
|
||||
key={a.attachment_id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-gray-50 border border-gray-200 rounded text-xs"
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 min-w-0 truncate text-gray-800">{a.filename}</span>
|
||||
<span className="text-gray-400 tabular-nums shrink-0">
|
||||
{(a.size_bytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
<button
|
||||
onClick={() => dropAttachment(a.attachment_id)}
|
||||
className="p-0.5 rounded hover:bg-gray-200 text-gray-500 shrink-0"
|
||||
aria-label={`移除 ${a.filename}`}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-2.5 py-1 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50 disabled:opacity-40 flex items-center gap-1.5"
|
||||
>
|
||||
{uploading ? (
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
添加附件
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => addFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">状态</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value as CalendarEvent['status'])}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="paused">暂停(不再触发提醒)</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 shrink-0 flex-wrap">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!canSave}
|
||||
title={recipients.length === 0 ? '至少要有一个收件人' : undefined}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{editing ? '保存' : '创建'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{editing && (
|
||||
<div className="w-full sm:w-auto sm:ml-auto">
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-600">确定删除?</span>
|
||||
<button
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-40 flex items-center gap-1"
|
||||
>
|
||||
{deleting && <SpinnerIcon className="w-3 h-3 animate-spin" />}
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="px-3 py-1.5 text-red-600 text-sm rounded hover:bg-red-50 flex items-center gap-1.5"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
775
client/electron/src/components/CalendarView.tsx
Normal file
775
client/electron/src/components/CalendarView.tsx
Normal file
@ -0,0 +1,775 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { CalendarEvent } from '../types';
|
||||
import CalendarEventEditor from './CalendarEventEditor';
|
||||
import NarrowStack from './NarrowStack';
|
||||
import {
|
||||
monthGrid,
|
||||
weekDays,
|
||||
bucketByDay,
|
||||
dayKey,
|
||||
isSameDay,
|
||||
addDays,
|
||||
addMonths,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
remindAt,
|
||||
describeRemindBefore
|
||||
} from '../lib/calendar';
|
||||
import {
|
||||
cellLunarLabel,
|
||||
formatSolarWithLunar,
|
||||
describeRecurrenceRule,
|
||||
isLunarRecurrence
|
||||
} from '../lib/lunar';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
DownloadIcon,
|
||||
UploadIcon,
|
||||
BellIcon,
|
||||
RepeatIcon,
|
||||
BotIcon,
|
||||
PauseIcon,
|
||||
UsersIcon,
|
||||
CloseIcon
|
||||
} from './icons';
|
||||
|
||||
type Scale = 'month' | 'week' | 'day';
|
||||
|
||||
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
/**
|
||||
* 日历主视图。
|
||||
*
|
||||
* **布局与全站一致:内容区自己再分两栏。**
|
||||
*
|
||||
* 之前这里是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做,
|
||||
* 而点「新建」时编辑器**顶掉**整个日历,人失去了正在看的那个月的上下文。
|
||||
* 两个问题同源:日历没有「详情栏」这个位置。
|
||||
*
|
||||
* 现在左边是网格、右边是常驻面板:默认显示选中那天的日程(所以永远不空),
|
||||
* 新建/编辑时同一个位置变成编辑器 —— 与「新建邮件是右侧整页」同一套语言。
|
||||
*
|
||||
* 窄屏放不下两栏,退回覆盖式(NarrowStack),与收件箱的行为一致。
|
||||
*/
|
||||
export default function CalendarView() {
|
||||
const narrow = useIsNarrow();
|
||||
const [scale, setScale] = useState<Scale>('month');
|
||||
const [anchor, setAnchor] = useState(() => new Date());
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
// 右栏三态:编辑既有事件 / 新建(带预填时间)/ 看某天的日程
|
||||
const [editing, setEditing] = useState<CalendarEvent | null>(null);
|
||||
const [creating, setCreating] = useState<Date | null>(null);
|
||||
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
|
||||
// 窄屏下右栏是否已滑入。宽屏恒为 false(两栏并排,不需要覆盖)
|
||||
const [paneOpen, setPaneOpen] = useState(false);
|
||||
|
||||
const [importing, setImporting] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
/**
|
||||
* 查询区间一律按月视图的 42 格算(含邻月首尾几天)。
|
||||
*
|
||||
* 三种粒度共用同一份数据:切 scale 不必重新请求,也不会出现
|
||||
* 「周视图跨月时后半周空白」。
|
||||
*/
|
||||
const range = useMemo(() => {
|
||||
const cells = monthGrid(anchor);
|
||||
return {
|
||||
from: startOfDay(cells[0]).toISOString(),
|
||||
to: endOfDay(cells[cells.length - 1]).toISOString()
|
||||
};
|
||||
}, [anchor]);
|
||||
|
||||
async function load() {
|
||||
setErr('');
|
||||
try {
|
||||
const r = await api.listCalendarEvents({ from: range.from, to: range.to });
|
||||
setEvents(r.events ?? []);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
load();
|
||||
}, [range.from, range.to]);
|
||||
|
||||
const byDay = useMemo(() => bucketByDay(events), [events]);
|
||||
|
||||
function shift(dir: 1 | -1) {
|
||||
if (scale === 'month') setAnchor(a => addMonths(a, dir));
|
||||
else if (scale === 'week') setAnchor(a => addDays(a, dir * 7));
|
||||
else setAnchor(a => addDays(a, dir));
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
const now = new Date();
|
||||
setAnchor(now);
|
||||
setSelectedDay(now);
|
||||
}
|
||||
|
||||
/** 点某天:宽屏只换右栏内容,窄屏滑入右栏。 */
|
||||
function pickDay(d: Date) {
|
||||
setSelectedDay(d);
|
||||
setEditing(null);
|
||||
setCreating(null);
|
||||
if (narrow) setPaneOpen(true);
|
||||
}
|
||||
|
||||
function pickEvent(e: CalendarEvent) {
|
||||
setEditing(e);
|
||||
setCreating(null);
|
||||
if (narrow) setPaneOpen(true);
|
||||
}
|
||||
|
||||
function startCreate(at: Date) {
|
||||
setCreating(at);
|
||||
setEditing(null);
|
||||
setSelectedDay(at);
|
||||
if (narrow) setPaneOpen(true);
|
||||
}
|
||||
|
||||
function closePane() {
|
||||
setEditing(null);
|
||||
setCreating(null);
|
||||
setPaneOpen(false);
|
||||
}
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (scale === 'month') return `${anchor.getFullYear()} 年 ${anchor.getMonth() + 1} 月`;
|
||||
if (scale === 'week') {
|
||||
const a = startOfWeek(anchor);
|
||||
const b = endOfWeek(anchor);
|
||||
// 跨月时两头都写月份,否则「9月28日 - 4日」看不出后者是十月
|
||||
if (a.getMonth() === b.getMonth()) {
|
||||
return `${a.getFullYear()} 年 ${a.getMonth() + 1} 月 ${a.getDate()}–${b.getDate()} 日`;
|
||||
}
|
||||
return `${a.getMonth() + 1}.${a.getDate()} – ${b.getMonth() + 1}.${b.getDate()}`;
|
||||
}
|
||||
return `${anchor.getFullYear()} 年 ${anchor.getMonth() + 1} 月 ${anchor.getDate()} 日 周${
|
||||
WEEKDAY_LABELS[(anchor.getDay() + 6) % 7]
|
||||
}`;
|
||||
}, [scale, anchor]);
|
||||
|
||||
async function doExport() {
|
||||
try {
|
||||
const ics = await api.exportCalendarICS(range.from, range.to);
|
||||
// Blob 下载而不是导航:导航会丢掉 cookie 之外的认证头
|
||||
const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `agentmail-${dayKey(anchor)}.ics`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '导出失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport(f: File) {
|
||||
setImporting(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = await api.importCalendarICS(await f.text());
|
||||
await load();
|
||||
setErr(`已导入 ${r.imported} 个事件${r.skipped ? `,跳过 ${r.skipped} 个` : ''}`);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 左栏:工具条 + 网格 ───
|
||||
const gridPane = (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-gray-200 shrink-0 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => shift(-1)}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => shift(1)}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToday}
|
||||
className="px-2.5 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-700"
|
||||
>
|
||||
今天
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-900 min-w-0">
|
||||
<CalendarIcon className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className="flex rounded border border-gray-300 overflow-hidden">
|
||||
{(['month', 'week', 'day'] as Scale[]).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setScale(s)}
|
||||
className={`px-2.5 py-1 text-xs ${
|
||||
scale === s ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{s === 'month' ? '月' : s === 'week' ? '周' : '日'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={doExport}
|
||||
title="导出 .ics"
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
|
||||
aria-label="导出"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
title="导入 .ics"
|
||||
disabled={importing}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600 disabled:opacity-40"
|
||||
aria-label="导入"
|
||||
>
|
||||
{importing ? <SpinnerIcon className="w-4 h-4 animate-spin" /> : <UploadIcon />}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".ics,text/calendar"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) doImport(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => startCreate(atNineOClock(selectedDay))}
|
||||
className="px-2.5 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 flex items-center gap-1"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="px-3 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-800 shrink-0">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||
<SpinnerIcon className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : scale === 'month' ? (
|
||||
<MonthGrid
|
||||
anchor={anchor}
|
||||
selectedDay={selectedDay}
|
||||
byDay={byDay}
|
||||
onPickDay={pickDay}
|
||||
onPickEvent={pickEvent}
|
||||
onCreateAt={startCreate}
|
||||
/>
|
||||
) : scale === 'week' ? (
|
||||
<WeekGrid
|
||||
anchor={anchor}
|
||||
selectedDay={selectedDay}
|
||||
byDay={byDay}
|
||||
onPickDay={pickDay}
|
||||
onPickEvent={pickEvent}
|
||||
onCreateAt={startCreate}
|
||||
/>
|
||||
) : (
|
||||
<DayGrid
|
||||
anchor={anchor}
|
||||
events={byDay.get(dayKey(anchor)) ?? []}
|
||||
onPickEvent={pickEvent}
|
||||
onCreateAt={startCreate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── 右栏:编辑器 / 当日日程 ───
|
||||
const sidePane =
|
||||
editing || creating ? (
|
||||
<CalendarEventEditor
|
||||
event={editing}
|
||||
initialTime={creating ?? undefined}
|
||||
onClose={closePane}
|
||||
onSaved={load}
|
||||
/>
|
||||
) : (
|
||||
<DayAgendaPane
|
||||
day={selectedDay}
|
||||
events={byDay.get(dayKey(selectedDay)) ?? []}
|
||||
onPickEvent={pickEvent}
|
||||
onCreate={() => startCreate(atNineOClock(selectedDay))}
|
||||
onClose={narrow ? closePane : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
// 窄屏:右栏覆盖在网格上,滑入滑出(与收件箱详情同一套动画)
|
||||
if (narrow) {
|
||||
return <NarrowStack base={gridPane} overlay={sidePane} open={paneOpen} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex min-h-0">
|
||||
{gridPane}
|
||||
<div className="w-full lg:w-[400px] shrink-0 border-l border-gray-200 bg-white flex flex-col min-h-0">
|
||||
{sidePane}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 新建事件的默认时刻:那天上午 9 点,比「现在」有用得多。 */
|
||||
function atNineOClock(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(9, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function hhmm(iso: string): string {
|
||||
const t = new Date(iso);
|
||||
if (Number.isNaN(t.getTime())) return '';
|
||||
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 收件人数量标记。多收件人是常态,格子里得看得出来。 */
|
||||
function recipientCount(e: CalendarEvent): number {
|
||||
if (e.recipients?.length) return e.recipients.length;
|
||||
return e.to_address || e.agent_name ? 1 : 0;
|
||||
}
|
||||
|
||||
/** 事件在格子里的小色条。 */
|
||||
function EventChip({
|
||||
e,
|
||||
onClick,
|
||||
showTime = true
|
||||
}: {
|
||||
e: CalendarEvent;
|
||||
onClick: () => void;
|
||||
showTime?: boolean;
|
||||
}) {
|
||||
// 暂停/取消的事件不会触发提醒,视觉上必须与生效的区分开 ——
|
||||
// 否则人以为设好了,实际到点什么都不会发生
|
||||
const dead = e.status !== 'active';
|
||||
const n = recipientCount(e);
|
||||
return (
|
||||
<button
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
title={`${e.title}${n > 1 ? `(${n} 个收件人)` : ''}`}
|
||||
className={`w-full text-left px-1.5 py-0.5 rounded text-xs truncate flex items-center gap-1 ${
|
||||
dead
|
||||
? 'bg-gray-100 text-gray-400 line-through'
|
||||
: 'bg-blue-50 text-blue-800 hover:bg-blue-100'
|
||||
}`}
|
||||
>
|
||||
{dead && <PauseIcon className="w-3 h-3 shrink-0" />}
|
||||
{showTime && <span className="tabular-nums shrink-0 opacity-70">{hhmm(e.event_time)}</span>}
|
||||
<span className="truncate">{e.title}</span>
|
||||
{n > 1 && <span className="shrink-0 opacity-70 tabular-nums">·{n}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthGrid({
|
||||
anchor,
|
||||
selectedDay,
|
||||
byDay,
|
||||
onPickDay,
|
||||
onPickEvent,
|
||||
onCreateAt
|
||||
}: {
|
||||
anchor: Date;
|
||||
selectedDay: Date;
|
||||
byDay: Map<string, CalendarEvent[]>;
|
||||
onPickDay: (d: Date) => void;
|
||||
onPickEvent: (e: CalendarEvent) => void;
|
||||
onCreateAt: (d: Date) => void;
|
||||
}) {
|
||||
const cells = useMemo(() => monthGrid(anchor), [anchor]);
|
||||
const now = new Date();
|
||||
const curMonth = startOfMonth(anchor).getMonth();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
||||
<div className="grid grid-cols-7 border-b border-gray-200 shrink-0 sticky top-0 bg-white z-10">
|
||||
{WEEKDAY_LABELS.map(w => (
|
||||
<div key={w} className="px-2 py-1.5 text-xs font-medium text-gray-500 text-center">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 grid-rows-6 flex-1 min-h-[30rem]">
|
||||
{cells.map((d, i) => {
|
||||
const list = byDay.get(dayKey(d)) ?? [];
|
||||
const outside = d.getMonth() !== curMonth;
|
||||
const isToday = isSameDay(d, now);
|
||||
const isPicked = isSameDay(d, selectedDay);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onPickDay(d)}
|
||||
onDoubleClick={() => onCreateAt(atNineOClock(d))}
|
||||
className={`border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden cursor-pointer ${
|
||||
isPicked ? 'bg-blue-50/70 ring-1 ring-inset ring-blue-300' : outside ? 'bg-gray-50/60' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-1 shrink-0">
|
||||
<span
|
||||
className={`px-1.5 rounded text-xs tabular-nums ${
|
||||
isToday
|
||||
? 'bg-blue-600 text-white font-medium'
|
||||
: outside
|
||||
? 'text-gray-400'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{d.getDate()}
|
||||
</span>
|
||||
{/* 农历日必须显示:农历重复规则的公历日期每次都在变,
|
||||
不显示农历人无法确认「每月十五」到底落在哪一格 */}
|
||||
<span
|
||||
className={`text-[10px] leading-none truncate ${
|
||||
outside ? 'text-gray-300' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{cellLunarLabel(d)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
{list.slice(0, 3).map(e => (
|
||||
<EventChip key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
|
||||
))}
|
||||
{list.length > 3 && (
|
||||
<span className="px-1.5 text-xs text-gray-500">还有 {list.length - 3} 项</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekGrid({
|
||||
anchor,
|
||||
selectedDay,
|
||||
byDay,
|
||||
onPickDay,
|
||||
onPickEvent,
|
||||
onCreateAt
|
||||
}: {
|
||||
anchor: Date;
|
||||
selectedDay: Date;
|
||||
byDay: Map<string, CalendarEvent[]>;
|
||||
onPickDay: (d: Date) => void;
|
||||
onPickEvent: (e: CalendarEvent) => void;
|
||||
onCreateAt: (d: Date) => void;
|
||||
}) {
|
||||
const days = useMemo(() => weekDays(anchor), [anchor]);
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="grid grid-cols-7 min-w-[36rem] h-full">
|
||||
{days.map((d, i) => {
|
||||
const list = byDay.get(dayKey(d)) ?? [];
|
||||
const isToday = isSameDay(d, now);
|
||||
const isPicked = isSameDay(d, selectedDay);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onPickDay(d)}
|
||||
onDoubleClick={() => onCreateAt(atNineOClock(d))}
|
||||
className={`border-r border-gray-100 flex flex-col min-h-[28rem] cursor-pointer ${
|
||||
isPicked ? 'bg-blue-50/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`px-2 py-1.5 border-b border-gray-200 sticky top-0 z-10 ${
|
||||
isToday ? 'bg-blue-50' : isPicked ? 'bg-blue-50/70' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-500">{WEEKDAY_LABELS[i]}</div>
|
||||
<div
|
||||
className={`text-sm tabular-nums ${
|
||||
isToday ? 'text-blue-700 font-medium' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{d.getMonth() + 1}.{d.getDate()}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 leading-none">{cellLunarLabel(d)}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-1 flex flex-col gap-1">
|
||||
{list.length === 0 ? (
|
||||
<div className="text-xs text-gray-300 px-1 py-2">—</div>
|
||||
) : (
|
||||
list.map(e => <EventChip key={e.event_id} e={e} onClick={() => onPickEvent(e)} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 日视图:按小时排。空的小时折叠成细条,否则 24 行等高会把有内容的挤出屏幕。 */
|
||||
function DayGrid({
|
||||
anchor,
|
||||
events,
|
||||
onPickEvent,
|
||||
onCreateAt
|
||||
}: {
|
||||
anchor: Date;
|
||||
events: CalendarEvent[];
|
||||
onPickEvent: (e: CalendarEvent) => void;
|
||||
onCreateAt: (d: Date) => void;
|
||||
}) {
|
||||
const byHour = useMemo(() => {
|
||||
const m = new Map<number, CalendarEvent[]>();
|
||||
for (const e of events) {
|
||||
const t = new Date(e.event_time);
|
||||
if (Number.isNaN(t.getTime())) continue;
|
||||
const h = t.getHours();
|
||||
const b = m.get(h);
|
||||
if (b) b.push(e);
|
||||
else m.set(h, [e]);
|
||||
}
|
||||
return m;
|
||||
}, [events]);
|
||||
|
||||
const nowHour = isSameDay(anchor, new Date()) ? new Date().getHours() : -1;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="px-3 py-2 text-xs text-gray-500 border-b border-gray-100">
|
||||
{formatSolarWithLunar(anchor)}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{HOURS.map(h => {
|
||||
const list = byHour.get(h) ?? [];
|
||||
const at = new Date(anchor);
|
||||
at.setHours(h, 0, 0, 0);
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
onDoubleClick={() => onCreateAt(at)}
|
||||
className={`flex gap-3 px-3 ${list.length ? 'py-2' : 'py-1 hover:bg-gray-50'} ${
|
||||
h === nowHour ? 'bg-blue-50/40' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-10 shrink-0 text-xs tabular-nums ${
|
||||
list.length ? 'text-gray-500 pt-1' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</span>
|
||||
{list.length === 0 ? (
|
||||
<span className="text-xs text-gray-200">—</span>
|
||||
) : (
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||
{list.map(e => (
|
||||
<EventRow key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 右栏默认内容:选中那天的日程。
|
||||
*
|
||||
* 这一栏的存在本身就是为了「宽屏右边不空着」—— 所以它必须在**没有**
|
||||
* 任何事件时也有话说(提示怎么新建),而不是渲染一片空白。
|
||||
*/
|
||||
function DayAgendaPane({
|
||||
day,
|
||||
events,
|
||||
onPickEvent,
|
||||
onCreate,
|
||||
onClose
|
||||
}: {
|
||||
day: Date;
|
||||
events: CalendarEvent[];
|
||||
onPickEvent: (e: CalendarEvent) => void;
|
||||
onCreate: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const isToday = isSameDay(day, new Date());
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-white">
|
||||
<div className="px-4 py-3 border-b border-gray-200 shrink-0 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-medium text-gray-900">
|
||||
{day.getMonth() + 1} 月 {day.getDate()} 日
|
||||
</h2>
|
||||
<span className="text-xs text-gray-500">
|
||||
周{WEEKDAY_LABELS[(day.getDay() + 6) % 7]}
|
||||
</span>
|
||||
{isToday && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-blue-600 text-white rounded">今天</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{formatSolarWithLunar(day)}</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-500 shrink-0"
|
||||
aria-label="返回日历"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>这一天没有日程。</p>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
在网格上双击任意格子也能直接新建。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{events.map(e => (
|
||||
<EventRow key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-gray-200 shrink-0">
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="w-full px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
在这一天新建日程
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 完整事件行:收件方 / 提醒时刻 / 重复规则都写出来。 */
|
||||
function EventRow({ e, onClick }: { e: CalendarEvent; onClick: () => void }) {
|
||||
const t = new Date(e.event_time);
|
||||
const rt = remindAt(e);
|
||||
const dead = e.status !== 'active';
|
||||
const recips = e.recipients?.length ? e.recipients : [e.to_address || e.agent_name].filter(Boolean);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2 rounded border ${
|
||||
dead
|
||||
? 'bg-gray-50 border-gray-200'
|
||||
: 'bg-white border-gray-200 hover:border-blue-300 hover:bg-blue-50/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-500 tabular-nums shrink-0">{hhmm(e.event_time)}</span>
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${
|
||||
dead ? 'text-gray-400 line-through' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{e.title}
|
||||
</span>
|
||||
{dead && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded shrink-0">
|
||||
{e.status === 'paused' ? '已暂停' : '已取消'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{e.description && (
|
||||
<p className="text-xs text-gray-600 mb-1.5 line-clamp-2">{e.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
{recips.length > 0 && (
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
{recips.length > 1 ? (
|
||||
<UsersIcon className="w-3 h-3 shrink-0" />
|
||||
) : (
|
||||
<BotIcon className="w-3 h-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{recips.length > 1
|
||||
? `${recips.length} 人 · ${e.delivery_mode === 'together' ? '同一线索' : '各自独立'}`
|
||||
: recips[0]}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<BellIcon className="w-3 h-3" />
|
||||
{describeRemindBefore(e.remind_before)}
|
||||
{e.remind_before > 0 && (
|
||||
<span className="tabular-nums opacity-70">
|
||||
({String(rt.getHours()).padStart(2, '0')}:{String(rt.getMinutes()).padStart(2, '0')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{e.recurrence !== 'none' && (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${
|
||||
isLunarRecurrence(e.recurrence) ? 'text-amber-700' : ''
|
||||
}`}
|
||||
>
|
||||
<RepeatIcon className="w-3 h-3" />
|
||||
{describeRecurrenceRule(e.recurrence, Number.isNaN(t.getTime()) ? undefined : t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
349
client/electron/src/components/ComposePage.tsx
Normal file
349
client/electron/src/components/ComposePage.tsx
Normal file
@ -0,0 +1,349 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import * as api from '../api/client';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import NarrowOnly from './NarrowOnly';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
import { ComposeIcon, ChevronLeftIcon } from './icons';
|
||||
|
||||
/** 完整的写邮件页面,占据右侧整个区域 */
|
||||
export default function ComposePage() {
|
||||
const prefill = useUIStore(s => s.composePrefill);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const isNarrow = useIsNarrow();
|
||||
|
||||
const [to, setTo] = useState(prefill?.to ?? '');
|
||||
const [cc, setCc] = useState(prefill?.cc ?? '');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [sessionAlias, setSessionAlias] = useState('');
|
||||
// 本任务的往返预算。空 = 不限。
|
||||
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
|
||||
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||||
const [maxRounds, setMaxRounds] = useState('');
|
||||
// 权限档位(仅新建会话时生效):plan / workspace / full。
|
||||
const [permissionMode, setPermissionMode] = useState('workspace');
|
||||
// 收件 Agent 的默认预算;null = 还没查到(未注册的收件人也是 null)
|
||||
const [agentDefault, setAgentDefault] = useState<number | null>(null);
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [okMsg, setOkMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTo(prefill?.to ?? '');
|
||||
setCc(prefill?.cc ?? '');
|
||||
}, [prefill]);
|
||||
|
||||
// 三维地址的 name 位 = 收件 Agent 名
|
||||
const toName = to.trim().split('@')[0].trim();
|
||||
|
||||
// 收件人变了就重查该 Agent 的默认预算。
|
||||
// 只在新建会话时需要(续谈沿用会话已有预算),所以别的情况不打接口。
|
||||
const isNewTarget = /\.new\s*$/.test(to.trim());
|
||||
useEffect(() => {
|
||||
if (!isNewTarget || toName === '') {
|
||||
setAgentDefault(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
api
|
||||
.listAgents()
|
||||
.then(r => {
|
||||
if (!alive) return;
|
||||
const hit = r.agents?.find(a => a.agent_name === toName);
|
||||
setAgentDefault(hit?.default_rounds ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
// 查不到就不显示提示,不该因此打断写信
|
||||
if (alive) setAgentDefault(null);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [toName, isNewTarget]);
|
||||
|
||||
// 会话别名只在新建会话(地址以 .new 结尾)时有意义;
|
||||
// 命中已有会话或走默认会话时后端会忽略该字段。
|
||||
const isNewSession = /\.new\s*$/.test(to.trim());
|
||||
const aliasError =
|
||||
isNewSession && sessionAlias.trim() !== '' && /[.\s/@]/.test(sessionAlias.trim())
|
||||
? '别名不可含 . 空白 / 或 @'
|
||||
: isNewSession && sessionAlias.trim() === 'new'
|
||||
? '"new" 是寻址保留字'
|
||||
: null;
|
||||
|
||||
// 输入框的 placeholder:人在派活时该看得到「不填会是多少」
|
||||
const defaultRoundsHint =
|
||||
agentDefault === null ? '默认' : agentDefault === 0 ? '不限' : `默认 ${agentDefault}`;
|
||||
|
||||
const roundsError =
|
||||
maxRounds.trim() !== '' && !/^\d+$/.test(maxRounds.trim())
|
||||
? '预算必须是非负整数(0 = 不限)'
|
||||
: null;
|
||||
|
||||
const canSend =
|
||||
roundsError === null &&
|
||||
to.trim() !== '' &&
|
||||
subject.trim() !== '' &&
|
||||
body.trim() !== '' &&
|
||||
aliasError === null &&
|
||||
!sending;
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.sendMail(to.trim(), subject.trim(), body, {
|
||||
cc: cc.trim(),
|
||||
session_alias: isNewSession ? sessionAlias.trim() : '',
|
||||
attachment_ids: attachments.map(a => a.id),
|
||||
...(isNewSession && maxRounds.trim() !== ''
|
||||
? { max_rounds: Number(maxRounds.trim()) }
|
||||
: {}),
|
||||
...(permissionMode ? { permission_mode: permissionMode } : {}),
|
||||
});
|
||||
const where = res.session_alias
|
||||
? `会话别名 ${res.session_alias}`
|
||||
: `会话 ${res.session_id.slice(0, 8)}`;
|
||||
setOkMsg(
|
||||
res.budget_max ? `已发送 · ${where} · 预算 ${res.budget_max} 个来回` : `已发送 · ${where}`
|
||||
);
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
setTimeout(() => {
|
||||
setOkMsg(null);
|
||||
cancelCompose();
|
||||
}, 900);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white">
|
||||
<div className="shrink-0 sticky top-0 z-10 lg:static px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
|
||||
用 cancelCompose 而不是 showList:写信态本身要一起结束,
|
||||
只滑走覆盖层的话下次进列表又会弹回来 */}
|
||||
<NarrowOnly>
|
||||
<button
|
||||
onClick={cancelCompose}
|
||||
className="tap -ml-1 inline-flex items-center gap-0.5 py-1 pr-1 text-gray-500 active:bg-gray-100 rounded"
|
||||
aria-label="返回"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</NarrowOnly>
|
||||
<ComposeIcon className="w-4 h-4 text-blue-600 shrink-0" />
|
||||
<h2 className="text-sm font-semibold text-gray-900">新建邮件</h2>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setSessionAlias('');
|
||||
setMaxRounds('');
|
||||
setPermissionMode('');
|
||||
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
|
||||
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
|
||||
setAttachments([]);
|
||||
setError(null);
|
||||
}}
|
||||
className="tap text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-4 md:px-6 py-4 space-y-3 border-b border-gray-200">
|
||||
<Field label="收件人" hint="name@path.session:省略=默认会话,new=新建,别名=已有会话">
|
||||
<AddressInput
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
autoFocus={!isNarrow}
|
||||
placeholder="deepseekharness@/program.upadtefeature"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isNewSession && (
|
||||
<Field label="会话别名" hint="可选;命名后可用 name@path.别名 续谈,全局唯一">
|
||||
<input
|
||||
value={sessionAlias}
|
||||
onChange={e => setSessionAlias(e.target.value)}
|
||||
placeholder="refactor-auth"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
{aliasError && <span className="text-[10px] text-red-600">{aliasError}</span>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="权限档位" hint={isNewSession ? "Agent 在这类任务里被允许动手的程度" : "改了即刻生效(该会话的档位会更新)"}>
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
|
||||
{ value: 'workspace', label: '目录内', desc: '越界问人' },
|
||||
{ value: 'full', label: '全权', desc: '自动放行' },
|
||||
].map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => setPermissionMode(o.value)}
|
||||
title={o.desc}
|
||||
className={`px-2.5 py-1.5 rounded-md border text-xs font-medium transition-colors ${
|
||||
permissionMode === o.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{isNewSession && (
|
||||
<Field label="往返预算" hint="留空 = 用该 Agent 的默认值;之后可在对话页随时调整">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={maxRounds}
|
||||
onChange={e => setMaxRounds(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder={defaultRoundsHint}
|
||||
className="w-24 text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
个来回后 Agent 停止主动发信(自动转发的总结与权限询问不占预算)
|
||||
</span>
|
||||
</div>
|
||||
{roundsError && <span className="text-[10px] text-red-600">{roundsError}</span>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="抄送" hint="多个地址用逗号分隔">
|
||||
<AddressInput value={cc} onChange={setCc} allowMultiple placeholder="pi@root.new" />
|
||||
</Field>
|
||||
|
||||
<Field label="主题">
|
||||
<input
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="更新特性分支"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 lg:flex-1 lg:min-h-0 px-4 md:px-6 py-3 flex flex-col">
|
||||
<div className="shrink-0 flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] font-medium text-gray-500">正文(Markdown)</span>
|
||||
<div className="flex-1" />
|
||||
<Toggle active={!preview} onClick={() => setPreview(false)}>
|
||||
编辑
|
||||
</Toggle>
|
||||
<Toggle active={preview} onClick={() => setPreview(true)}>
|
||||
预览
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="markdown flex-1 min-h-[12rem] lg:min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4">
|
||||
{body.trim() ? (
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">暂无内容</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
|
||||
className="flex-1 min-h-[12rem] lg:min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-y lg:resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 窄屏底部操作栏是 sticky,会在矮视口里向上吸附;预留一栏高度,
|
||||
避免它覆盖附件按钮。桌面操作栏回到普通文档流,不需要这段缓冲。 */}
|
||||
<div className="shrink-0 px-4 md:px-6 pb-20 lg:pb-3">
|
||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 sticky bottom-0 z-10 lg:static px-4 md:px-6 py-3 border-t border-gray-200 bg-white flex items-center gap-3 flex-wrap">
|
||||
{error && <span className="min-w-0 text-xs text-red-600 break-words">{error}</span>}
|
||||
{okMsg && <span className="min-w-0 text-xs text-green-700 break-words">{okMsg}</span>}
|
||||
<div className="flex-1 min-w-2" />
|
||||
<button
|
||||
onClick={cancelCompose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!canSend}
|
||||
className="px-5 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"
|
||||
>
|
||||
{sending ? '发送中' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<label className="text-[11px] font-medium text-gray-500">{label}</label>
|
||||
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
active,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`tap text-[11px] px-2 py-0.5 rounded ${
|
||||
active ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
37
client/electron/src/components/ConnectionIndicator.tsx
Normal file
37
client/electron/src/components/ConnectionIndicator.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { onSSEStatus, type SSEStatus } from '../api/sse';
|
||||
|
||||
/**
|
||||
* SSE 连接状态指示器。
|
||||
*
|
||||
* 实时性是 Agent 协作的核心体验:断线后用户以为系统正常,实际上通知已经停了。
|
||||
* 一个小小的绿/黄/红点就能避免「Agent 没在动」的误判。
|
||||
*
|
||||
* 不做成弹窗或横幅 —— 那会打断正在进行的对话。一个点足够了:
|
||||
* 会看它的人自然会看,不会看的人不需要被打扰。
|
||||
*/
|
||||
export function ConnectionIndicator() {
|
||||
const [status, setStatus] = useState<SSEStatus>('connecting');
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onSSEStatus(setStatus);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const map: Record<SSEStatus, { color: string; title: string }> = {
|
||||
connecting: { color: 'bg-yellow-400', title: '正在连接…' },
|
||||
connected: { color: 'bg-green-500', title: '已连接' },
|
||||
reconnecting: { color: 'bg-orange-400', title: '重连中…' },
|
||||
disconnected: { color: 'bg-red-400', title: '已断开' },
|
||||
};
|
||||
|
||||
const { color, title } = map[status] || map.disconnected;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${color} shrink-0 transition-colors duration-300`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
276
client/electron/src/components/ContactPanel.tsx
Normal file
276
client/electron/src/components/ContactPanel.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import type { Contact } from '../types';
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ComposeIcon,
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
ChevronRightIcon,
|
||||
ListViewIcon,
|
||||
CardViewIcon
|
||||
} from './icons';
|
||||
import { WorkCard } from './WorkCard';
|
||||
|
||||
/**
|
||||
* 左侧联系人面板:列出所有 name@path.session,支持
|
||||
* - 点击进入该会话
|
||||
* - 写信(预填收件人为该三维地址)
|
||||
* - 归档(Agent 侧会话归档 + 邮箱界面移除)
|
||||
*/
|
||||
export default function ContactPanel() {
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
const archivedContacts = useContactStore(s => s.archivedContacts);
|
||||
const showArchived = useContactStore(s => s.showArchived);
|
||||
const loading = useContactStore(s => s.loading);
|
||||
const error = useContactStore(s => s.error);
|
||||
const pendingArchive = useContactStore(s => s.pendingArchive);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const toggleArchivedView = useContactStore(s => s.toggleArchivedView);
|
||||
const requestArchive = useContactStore(s => s.requestArchive);
|
||||
const cancelArchive = useContactStore(s => s.cancelArchive);
|
||||
const archive = useContactStore(s => s.archive);
|
||||
const view = useContactStore(s => s.view);
|
||||
const setView = useContactStore(s => s.setView);
|
||||
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
const currentSession = useSessionStore(s => s.currentSession);
|
||||
const clearCurrentMail = useMailStore(s => s.clearCurrentMail);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const showDetail = useUIStore(s => s.showDetail);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, []);
|
||||
|
||||
const open = (c: Contact) => {
|
||||
cancelCompose();
|
||||
clearCurrentMail();
|
||||
selectSession(c.session_id);
|
||||
showDetail(); // 窄屏下切到会话内容栏
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
|
||||
// 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
|
||||
view === 'card' ? 'lg:w-[400px]' : 'lg:w-[320px]'
|
||||
}`}
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">
|
||||
{view === 'card' ? '工作列表' : '联系人'}
|
||||
</h2>
|
||||
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
|
||||
<div className="flex-1" />
|
||||
{/* 视图切换:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」 */}
|
||||
<button
|
||||
onClick={() => setView(view === 'list' ? 'card' : 'list')}
|
||||
title={view === 'list' ? '切换到卡片视图' : '切换到列表视图'}
|
||||
className="tap p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{view === 'list' ? (
|
||||
<CardViewIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ListViewIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleArchivedView}
|
||||
className={`tap text-[11px] px-1.5 py-0.5 rounded ${
|
||||
showArchived ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 py-2 text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">加载中</p>
|
||||
)}
|
||||
|
||||
{contacts.map(c =>
|
||||
// 归档确认态两种视图共用同一个确认框:那是个破坏性操作,
|
||||
// 换个视图就换套确认 UI 只会让人对「点了什么」更没底
|
||||
pendingArchive === c.address ? (
|
||||
<ArchiveConfirm
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
onCancel={cancelArchive}
|
||||
onConfirm={() => archive(c)}
|
||||
/>
|
||||
) : view === 'card' ? (
|
||||
<WorkCard
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
onArchive={() => requestArchive(c.address)}
|
||||
/>
|
||||
) : (
|
||||
<ContactRow
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
onRequestArchive={() => requestArchive(c.address)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{!loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">
|
||||
{view === 'card'
|
||||
? '暂无进行中的工作,发一封邮件即可开始'
|
||||
: '暂无联系人,发一封邮件即可建立'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showArchived && (
|
||||
<div className="pt-3 mt-2 border-t border-gray-200">
|
||||
<p className="px-2 pb-1 text-[11px] font-medium text-gray-400">
|
||||
已归档 {archivedContacts.length}
|
||||
</p>
|
||||
{archivedContacts.map(c => (
|
||||
<div
|
||||
key={c.session_id}
|
||||
className="px-3 py-2 rounded-lg opacity-60 hover:opacity-100 hover:bg-gray-50"
|
||||
>
|
||||
<p className="text-xs font-mono text-gray-500 truncate">{c.address}</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{c.mail_count} 封 · 已归档
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{archivedContacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-3">无归档会话</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档确认框。
|
||||
*
|
||||
* 列表视图与卡片视图共用:归档是破坏性操作,换个视图就换套确认 UI
|
||||
* 只会让人对「自己点了什么」更没底。
|
||||
*/
|
||||
function ArchiveConfirm({
|
||||
contact,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
contact: Contact;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
|
||||
<p className="text-xs text-gray-800">
|
||||
归档 <span className="font-mono">{contact.address}</span>?
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||
对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
确认归档
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactRow({
|
||||
contact,
|
||||
active,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onRequestArchive
|
||||
}: {
|
||||
contact: Contact;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onCompose: () => void;
|
||||
onRequestArchive: () => void;
|
||||
}) {
|
||||
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group px-3 py-2.5 rounded-lg border transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onOpen} className="w-full text-left">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">
|
||||
{contact.agent_name}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
|
||||
{contact.unread_count > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{contact.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-600 font-mono truncate">
|
||||
{contact.session_alias || '(未命名会话)'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{contact.mail_count} 封 · {time}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div className="reveal flex gap-1 mt-1.5">
|
||||
<button
|
||||
onClick={onCompose}
|
||||
title="写信给该地址"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white"
|
||||
>
|
||||
<ComposeIcon className="w-3 h-3" />
|
||||
写信
|
||||
</button>
|
||||
<button
|
||||
onClick={onRequestArchive}
|
||||
title="归档该 name@path.session"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
|
||||
>
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
client/electron/src/components/KeyPanel.tsx
Normal file
311
client/electron/src/components/KeyPanel.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import { useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
|
||||
|
||||
/** 密钥类型的中文说明,创建表单与列表共用一份文案 */
|
||||
export const KEY_TYPE_LABEL: Record<api.KeyType, string> = {
|
||||
permanent: '长期',
|
||||
one_time: '一次性',
|
||||
timed: '限时'
|
||||
};
|
||||
|
||||
const KEY_TYPE_HINT: Record<api.KeyType, string> = {
|
||||
permanent: '永不过期,可重复使用',
|
||||
one_time: '首次使用后立即失效',
|
||||
timed: '指定小时数后过期'
|
||||
};
|
||||
|
||||
/** 一条密钥在列表里的状态:过期/已用完/可用 */
|
||||
function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at: string | null }) {
|
||||
if (k.key_type === 'one_time' && k.used_at) return { text: '已使用', cls: 'text-gray-400' };
|
||||
if (k.key_type === 'timed' && k.expires_at && new Date(k.expires_at) < new Date())
|
||||
return { text: '已过期', cls: 'text-red-500' };
|
||||
return { text: '可用', cls: 'text-green-600' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 新签发密钥的一次性展示条。
|
||||
*
|
||||
* 密钥全文只在创建响应里出现一次,服务端之后只返回前 8 位,
|
||||
* 所以这里必须明确提示「关掉就再也看不到」,而不是让用户以为随时能回来复制。
|
||||
*/
|
||||
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* 无剪贴板权限时用户可手动选中 */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-amber-300 bg-amber-50 rounded-md p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-amber-900">
|
||||
密钥已创建。全文仅显示这一次,关闭后无法再次查看。
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-[11px] font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all">
|
||||
{token}
|
||||
</code>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="tap shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
<button onClick={onDismiss} className="tap shrink-0 text-xs text-amber-800 hover:underline">
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateFormProps {
|
||||
/** Agent 密钥面板会多出「绑定 Agent」与「登记已有密钥」两项 */
|
||||
variant: 'agent' | 'user';
|
||||
busy: boolean;
|
||||
onSubmit: (payload: api.CreateKeyPayload) => void;
|
||||
}
|
||||
|
||||
function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyType, setKeyType] = useState<api.KeyType>('permanent');
|
||||
const [label, setLabel] = useState('');
|
||||
const [hours, setHours] = useState(24);
|
||||
const [agentName, setAgentName] = useState('');
|
||||
const [keyToken, setKeyToken] = useState('');
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
新建密钥
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const payload: api.CreateKeyPayload = { key_type: keyType, label: label.trim() };
|
||||
if (keyType === 'timed') payload.expires_hours = hours;
|
||||
if (variant === 'agent') {
|
||||
if (agentName.trim()) payload.agent_name = agentName.trim();
|
||||
if (keyToken.trim()) payload.key_token = keyToken.trim();
|
||||
}
|
||||
onSubmit(payload);
|
||||
setOpen(false);
|
||||
setLabel('');
|
||||
setAgentName('');
|
||||
setKeyToken('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setKeyType(t)}
|
||||
className={`text-left px-2.5 py-2 rounded border text-xs ${
|
||||
keyType === t
|
||||
? 'border-blue-400 bg-white ring-2 ring-blue-100'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="备注(如 我的笔记本 / CI 机器)"
|
||||
className="flex-1 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
{keyType === 'timed' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={hours}
|
||||
onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))}
|
||||
className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-500">小时后过期</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variant === 'agent' && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={agentName}
|
||||
onChange={e => setAgentName(e.target.value)}
|
||||
placeholder="绑定到 Agent(留空 = 首次注册时自动落定)"
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<input
|
||||
value={keyToken}
|
||||
onChange={e => setKeyToken(e.target.value)}
|
||||
placeholder="登记插件本地生成的密钥(留空 = 由服务器生成)"
|
||||
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => setOpen(false)} className="text-xs text-gray-600 hover:text-gray-900">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
className="tap text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥面板。Agent 密钥(管理员)与用户连接密钥共用同一套渲染,
|
||||
* 差异用 variant 表达:只有 Agent 密钥能绑定 Agent 名、能登记客户端已生成的密钥。
|
||||
*/
|
||||
export default function KeyPanel({
|
||||
variant,
|
||||
keys,
|
||||
loading,
|
||||
error,
|
||||
newToken,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onBind,
|
||||
onDismissToken
|
||||
}: {
|
||||
variant: 'agent' | 'user';
|
||||
keys: (api.AgentKey | api.UserKey)[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
newToken: string | null;
|
||||
onCreate: (payload: api.CreateKeyPayload) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onBind?: (id: string, agentName: string) => void;
|
||||
onDismissToken: () => void;
|
||||
}) {
|
||||
const [bindingID, setBindingID] = useState<string | null>(null);
|
||||
const [bindName, setBindName] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{variant === 'agent' ? 'Agent 接入密钥' : '客户端连接密钥'}
|
||||
</h3>
|
||||
<div className="flex-1" />
|
||||
<CreateForm variant={variant} busy={loading} onSubmit={onCreate} />
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{variant === 'agent'
|
||||
? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。'
|
||||
: '第三方客户端用该密钥访问自己的邮箱(Authorization: Bearer)。它不能用于注册 Agent。'}
|
||||
</p>
|
||||
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
{newToken && <NewKeyBanner token={newToken} onDismiss={onDismissToken} />}
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 py-3">暂无密钥</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{keys.map(k => {
|
||||
const st = keyState(k);
|
||||
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
|
||||
return (
|
||||
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
|
||||
{k.token_hint}
|
||||
</code>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-gray-900 truncate">
|
||||
{k.label || <span className="text-gray-400">(无备注)</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">
|
||||
{KEY_TYPE_LABEL[k.key_type]}
|
||||
{k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`}
|
||||
{agentKey &&
|
||||
(agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] shrink-0 ${st.cls}`}>{st.text}</span>
|
||||
|
||||
{agentKey && onBind && bindingID === k.key_id ? (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<input
|
||||
value={bindName}
|
||||
onChange={e => setBindName(e.target.value)}
|
||||
placeholder="Agent 名"
|
||||
className="w-28 text-[11px] border border-gray-300 rounded px-1.5 py-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (bindName.trim()) onBind(k.key_id, bindName.trim());
|
||||
setBindingID(null);
|
||||
setBindName('');
|
||||
}}
|
||||
className="text-[11px] text-blue-600 hover:underline"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBindingID(null)}
|
||||
className="text-[11px] text-gray-500 hover:underline"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
agentKey &&
|
||||
onBind && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setBindingID(k.key_id);
|
||||
setBindName(agentKey.agent_name ?? '');
|
||||
}}
|
||||
className="text-[11px] text-gray-500 hover:text-gray-900 shrink-0"
|
||||
>
|
||||
绑定
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(k.key_id)}
|
||||
title="吊销"
|
||||
className="shrink-0 text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
client/electron/src/components/LoginPage.tsx
Normal file
114
client/electron/src/components/LoginPage.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { MailboxIcon, 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">
|
||||
<MailboxIcon 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>
|
||||
);
|
||||
}
|
||||
327
client/electron/src/components/MailList.tsx
Normal file
327
client/electron/src/components/MailList.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import type { Mail } from '../types';
|
||||
import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups';
|
||||
import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons';
|
||||
import { participantAddress } from '../lib/replyTarget';
|
||||
|
||||
export default function MailList() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const showDetail = useUIStore(s => s.showDetail);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
const sent = useMailStore(s => s.sent);
|
||||
const currentMail = useMailStore(s => s.currentMail);
|
||||
const selectMail = useMailStore(s => s.selectMail);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const clearSession = useSessionStore(s => s.clearSession);
|
||||
|
||||
// 哪些会话组被展开。默认全部折叠 —— 收件箱的问题正是「一次任务的几十封信
|
||||
// 淹掉其他任务」,默认展开等于没分组
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'sent') fetchSent();
|
||||
else if (viewMode === 'inbox') fetchInbox('all');
|
||||
}, [viewMode]);
|
||||
|
||||
// 切换收件箱/发件箱时收起所有组:两个箱子的会话集合不同,
|
||||
// 留着上一个箱子的展开状态会让人以为某个组「自己展开了」
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
}, [viewMode]);
|
||||
|
||||
const isSent = viewMode === 'sent';
|
||||
// 权限请求已经有自己的导航项(授权),收件箱只放要读的内容。
|
||||
// 不过滤的后果实测过:一个会话的 17 封权限邮件把另外两个会话的信挤出视野。
|
||||
// 发件箱不筛:人发不出权限请求(那是 Agent 发的),筛也筛不掉什么。
|
||||
const source = isSent ? sent : splitByPermission(inbox).normal;
|
||||
const list = source;
|
||||
const groups = groupMailsBySession(list);
|
||||
|
||||
const toggle = (sessionId: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sessionId)) next.delete(sessionId);
|
||||
else next.add(sessionId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const pick = (m: Mail) => {
|
||||
clearSession();
|
||||
cancelCompose();
|
||||
selectMail(m);
|
||||
// 窄屏下列表与详情共用一栏,选中后要切过去;
|
||||
// 宽屏下这个状态不影响渲染(两栏并排),但仍然维护 ——
|
||||
// 否则从窄屏拖宽再拖回来,用户会发现自己回到了列表,刚打开的邮件不见了
|
||||
showDetail();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
|
||||
「有几件事」,后者只是流量 */}
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{groups.length > 0 && groups.length !== list.length
|
||||
? `${groups.length} 组 · ${list.length} 封`
|
||||
: list.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||
{groups.map(g =>
|
||||
isFlatGroup(g) ? (
|
||||
<MailItem
|
||||
key={g.latest.mail_id}
|
||||
mail={g.latest}
|
||||
active={currentMail?.mail_id === g.latest.mail_id}
|
||||
showTo={isSent}
|
||||
onClick={() => pick(g.latest)}
|
||||
/>
|
||||
) : (
|
||||
<SessionGroup
|
||||
key={g.sessionId}
|
||||
group={g}
|
||||
showTo={isSent}
|
||||
open={expanded.has(g.sessionId)}
|
||||
onToggle={() => toggle(g.sessionId)}
|
||||
currentMailID={currentMail?.mail_id}
|
||||
onPick={pick}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{list.length === 0 && (
|
||||
<p className="text-xs text-gray-500 text-center py-6">暂无邮件</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个会话折叠成的一组。
|
||||
*
|
||||
* 组头答的是「哪件事、进行到哪」;展开后才是逐封邮件。
|
||||
* 权限请求不在这里 —— 它们在单独的「授权」导航项里。
|
||||
*/
|
||||
function SessionGroup({
|
||||
group,
|
||||
showTo,
|
||||
open,
|
||||
onToggle,
|
||||
currentMailID,
|
||||
onPick
|
||||
}: {
|
||||
group: MailGroup;
|
||||
showTo: boolean;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
currentMailID?: string;
|
||||
onPick: (m: Mail) => void;
|
||||
}) {
|
||||
const g = group;
|
||||
const time = new Date(g.latest.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// 列表行只显示「跟谁在通信」,**不带会话位**:分组头下面已经单独显示了
|
||||
// 会话别名,再拼一遍会让长别名(实测 92 字节)把这一行挤没。
|
||||
//
|
||||
// 人还是 Agent 走显式布尔;workspace 从会话取(from_workspace 对 Agent
|
||||
// 存的是 Agent 名而非路径)。
|
||||
const isPeerHuman = showTo ? g.latest.to_human : g.latest.from_human;
|
||||
const peer = participantAddress(
|
||||
showTo ? g.latest.to_name : g.latest.from_name,
|
||||
isPeerHuman,
|
||||
isPeerHuman ? '' : g.latest.session_workspace || ''
|
||||
);
|
||||
|
||||
// 组内含选中邮件时给个边框,否则展开一个组再滚下去会找不到自己在看哪封
|
||||
const hasActive = currentMailID ? g.mails.some(m => m.mail_id === currentMailID) : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border ${
|
||||
hasActive ? 'border-blue-200 bg-blue-50/40' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full text-left px-3 py-2.5 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${
|
||||
open ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs truncate flex-1 font-mono ${
|
||||
g.unreadCount > 0 ? 'font-semibold text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{showTo ? '→ ' : ''}
|
||||
{peer}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-0.5 pl-5">
|
||||
{g.unreadCount > 0 && (
|
||||
<span className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{g.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
g.unreadCount > 0 ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{g.subject}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
||||
<span className="text-[10px] text-blue-500 font-mono truncate">
|
||||
{g.alias ? `.${g.alias}` : '(未命名会话)'}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{g.mails.length} 封</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="pl-5 pr-1 pb-1.5 space-y-0.5">
|
||||
{g.mails.map(m => (
|
||||
<MailItem
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
showTo={showTo}
|
||||
onClick={() => onPick(m)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MailItem({
|
||||
mail,
|
||||
active,
|
||||
showTo,
|
||||
onClick,
|
||||
compact = false
|
||||
}: {
|
||||
mail: Mail;
|
||||
active: boolean;
|
||||
showTo: boolean;
|
||||
onClick: () => void;
|
||||
/** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const isPermission = mail.mail_type === 'permission_request';
|
||||
const isUnread = mail.status === 'unread';
|
||||
const ccCount = mail.cc_list?.length ?? 0;
|
||||
const attachCount = mail.attachments?.length ?? 0;
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const isRowHuman = showTo ? mail.to_human : mail.from_human;
|
||||
const peer = participantAddress(
|
||||
showTo ? mail.to_name : mail.from_name,
|
||||
isRowHuman,
|
||||
isRowHuman ? '' : mail.session_workspace || ''
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs truncate flex-1 ${
|
||||
compact ? '' : 'font-mono'
|
||||
} ${isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'}`}
|
||||
>
|
||||
{compact ? (
|
||||
<>
|
||||
{isUnread && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1.5 align-middle" />
|
||||
)}
|
||||
{mail.subject}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showTo ? '→ ' : ''}
|
||||
{peer}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
|
||||
{isPermission && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{/* 组内条目不重复显示别名(组头已有)。
|
||||
发件箱里仍可能出现权限邮件(理论上人发不出,但不假设数据一定干净) */}
|
||||
{compact && isPermission && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] ${
|
||||
mail.permission_result ? 'text-gray-400' : 'text-orange-600 font-medium'
|
||||
}`}
|
||||
>
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{mail.permission_result || '待决策'}
|
||||
</span>
|
||||
)}
|
||||
{!compact && mail.session_alias && (
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-blue-600 font-mono">.{mail.session_alias}</span>
|
||||
)}
|
||||
{ccCount > 0 && <span className="text-[10px] text-gray-400">抄送 {ccCount}</span>}
|
||||
{attachCount > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||||
{attachCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
864
client/electron/src/components/MailView.tsx
Normal file
864
client/electron/src/components/MailView.tsx
Normal file
@ -0,0 +1,864 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
sessionReplyTarget,
|
||||
mailReplyTarget,
|
||||
mailCounterpart,
|
||||
replyAllCC,
|
||||
participantAddress
|
||||
} from '../lib/replyTarget';
|
||||
import * as api from '../api/client';
|
||||
import type { Mail } from '../types';
|
||||
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
import ThreadView from './ThreadView';
|
||||
import PermissionChip, { permissionModeHint } from './PermissionChip';
|
||||
import BackButton from './BackButton';
|
||||
|
||||
export default function MailView() {
|
||||
const currentMail = useMailStore(s => s.currentMail);
|
||||
const markRead = useMailStore(s => s.markRead);
|
||||
const currentSession = useSessionStore(s => s.currentSession);
|
||||
const currentSessionMails = useSessionStore(s => s.currentSessionMails);
|
||||
// 当前登录用户名:判定「哪封是我发的」的唯一基准。
|
||||
// 曾经写死成 'human'(单用户时代的遗留),多用户下登录名可能是 jianf,
|
||||
// 判据恒为假 —— 于是回复自己发的信时对端取成了自己。
|
||||
const me = useAuthStore(s => s.user?.username || '');
|
||||
// 转发面板作用于哪封邮件;null = 未打开
|
||||
const [forwarding, setForwarding] = useState<Mail | null>(null);
|
||||
// 正在看哪封邮件的对话树;null = 看正常的邮件视图
|
||||
const [threadOf, setThreadOf] = useState<string | null>(null);
|
||||
|
||||
// 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索
|
||||
const currentMailID = currentMail?.mail_id;
|
||||
useEffect(() => {
|
||||
setThreadOf(null);
|
||||
}, [currentMailID]);
|
||||
|
||||
if (currentSession && currentSessionMails.length > 0) {
|
||||
const last = currentSessionMails[currentSessionMails.length - 1];
|
||||
// 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定:
|
||||
// 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的,
|
||||
// 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。
|
||||
const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me);
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<BackButton label="会话" />
|
||||
<span className="text-sm font-semibold text-gray-900 font-mono">
|
||||
{currentSession.session_alias
|
||||
? `.${currentSession.session_alias}`
|
||||
: '(未命名会话)'}
|
||||
</span>
|
||||
<StatusBadge status={currentSession.status} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{currentSessionMails.length} 封
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<PermissionEditor />
|
||||
<BudgetEditor />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
|
||||
</div>
|
||||
<RenameProposalBar />
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
|
||||
{currentSessionMails.map(m => (
|
||||
<ThreadCard key={m.mail_id} mail={m} onForward={() => setForwarding(m)} />
|
||||
))}
|
||||
</div>
|
||||
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 ——
|
||||
而人多数时间待在会话视图里,等于转发功能在 UI 上找不到 */}
|
||||
{forwarding ? (
|
||||
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
|
||||
) : (
|
||||
<ReplyBar replyTo={last} overrideTarget={sessionTarget} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentMail) {
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
|
||||
<div className="text-center">
|
||||
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<p className="text-sm mt-3 text-gray-500">选择一封邮件查看,或点击左侧「新建」写邮件</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (threadOf) {
|
||||
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<Header
|
||||
mail={currentMail}
|
||||
onRead={() => markRead(currentMail.mail_id)}
|
||||
onForward={() => setForwarding(currentMail)}
|
||||
onThread={() => setThreadOf(currentMail.mail_id)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<div className="markdown text-sm">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
|
||||
</div>
|
||||
<AttachmentList items={currentMail.attachments ?? []} />
|
||||
{currentMail.mail_type === 'permission_request' && (
|
||||
<PermissionPanel mail={currentMail} />
|
||||
)}
|
||||
</div>
|
||||
{forwarding ? (
|
||||
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
|
||||
) : (
|
||||
<ReplyBar replyTo={currentMail} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限档位编辑器(会话头部)。
|
||||
*
|
||||
* 人在派活时声明「这件事允许 Agent 动手到什么程度」。
|
||||
* 只有新会话时在 ComposePage 里设;对话页里随时可改(规划档位)。
|
||||
* 三档:plan(只读)/ workspace(目录内,越界问人)/ full(全权)。
|
||||
*/
|
||||
function PermissionEditor() {
|
||||
const session = useSessionStore(s => s.currentSession);
|
||||
const setPermissionMode = useSessionStore(s => s.setPermissionMode);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const mode = session.permission_mode || 'workspace';
|
||||
const enforcement = session.permission_enforcement || 'advisory';
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
title={permissionModeHint(mode, enforcement)}
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<PermissionChip mode={mode} enforcement={enforcement} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const modes = [
|
||||
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
|
||||
{ value: 'workspace', label: '目录内', desc: '越界问人' },
|
||||
{ value: 'full', label: '全权', desc: '自动放行' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{modes.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
await setPermissionMode(o.value);
|
||||
setBusy(false);
|
||||
setEditing(false);
|
||||
}}
|
||||
title={o.desc}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors disabled:opacity-40 ${
|
||||
mode === o.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setEditing(false)} className="text-[10px] text-gray-400 hover:text-gray-700 ml-0.5">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本任务的往返预算编辑器(会话头部)。
|
||||
*
|
||||
* 配额最该被编辑的地方就是这里:人看着往来内容才知道这件事还值不值得再来几个回合。
|
||||
* 放在管理员页面调某个 Agent 的全局配额是另一回事 —— 那管的是「这个 Agent 总共能发多少」,
|
||||
* 而不是「这件事值得多少个来回」。
|
||||
*/
|
||||
function BudgetEditor() {
|
||||
const budget = useSessionStore(s => s.budget);
|
||||
const setBudget = useSessionStore(s => s.setBudget);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!budget) return null;
|
||||
|
||||
const exhausted = !budget.unlimited && budget.remaining === 0;
|
||||
|
||||
const open = () => {
|
||||
setDraft(budget.unlimited ? '' : String(budget.max_rounds));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const commit = async (patch: { max_rounds?: number; reset?: boolean }) => {
|
||||
setBusy(true);
|
||||
await setBudget(patch);
|
||||
setBusy(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
onClick={open}
|
||||
title="本任务的往返预算:Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
|
||||
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${
|
||||
exhausted
|
||||
? 'border-red-200 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
<GaugeIcon className="w-3 h-3" />
|
||||
{budget.unlimited
|
||||
? '预算不限'
|
||||
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? ' · 已用尽' : ''}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-gray-500">往返预算</span>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
autoFocus
|
||||
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
invalid ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
disabled={busy || invalid}
|
||||
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
|
||||
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
disabled={busy || budget.used_rounds === 0}
|
||||
onClick={() => commit({ reset: true })}
|
||||
title="已用次数归零,上限不变"
|
||||
className="text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-[11px] text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 提议改会话别名的提示条。
|
||||
*
|
||||
* 为什么要人点头而不是让 Agent 直接改:别名是**人**的寻址入口(name@path.别名)。
|
||||
* Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
|
||||
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
*/
|
||||
function RenameProposalBar() {
|
||||
const proposal = useSessionStore(s => s.renameProposal);
|
||||
const current = useSessionStore(s => s.currentSession);
|
||||
const accept = useSessionStore(s => s.acceptRename);
|
||||
const dismiss = useSessionStore(s => s.dismissRename);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!proposal) return null;
|
||||
|
||||
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-2.5 bg-blue-50 border-b border-blue-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-900">
|
||||
Agent 建议把会话别名从 <span className="font-mono">{from}</span> 改为{' '}
|
||||
<span className="font-mono font-semibold">.{proposal.alias}</span>
|
||||
</p>
|
||||
{proposal.reason && (
|
||||
<p className="text-[11px] text-blue-700 mt-0.5">{proposal.reason}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-blue-500 mt-0.5">
|
||||
改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。
|
||||
接受后此别名不再被 Agent 平台的自动命名覆盖
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
await accept();
|
||||
setBusy(false);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded bg-blue-600 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{busy ? '改名中' : '接受'}
|
||||
</button>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="px-2.5 py-1 rounded border border-blue-200 text-blue-700 text-xs hover:bg-blue-100 shrink-0"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发面板。与回复并列,二者互斥显示 —— 同时开两个输入框会让人不知道自己在写哪个。
|
||||
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
|
||||
*/
|
||||
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [ccOpen, setCcOpen] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
|
||||
const submit = async () => {
|
||||
if (!to.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.forwardMail(mail.mail_id, {
|
||||
to: to.trim(),
|
||||
cc: cc.trim(),
|
||||
comment: comment.trim()
|
||||
});
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-[11px] font-medium text-gray-600">
|
||||
转发「{mail.subject}」
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setCcOpen(o => !o)}
|
||||
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
|
||||
>
|
||||
{ccOpen ? '收起抄送' : '抄送'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AddressInput value={to} onChange={setTo} placeholder="新收件人:pi@root.new" />
|
||||
|
||||
{ccOpen && (
|
||||
<AddressInput
|
||||
value={cc}
|
||||
onChange={setCc}
|
||||
allowMultiple
|
||||
placeholder="抄送:逗号分隔,可多个"
|
||||
/>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="转发说明(可选,置于引用原文之前;原文将以引用块附在下方)"
|
||||
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !to.trim()}
|
||||
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? '转发中' : '转发'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
mail,
|
||||
onRead,
|
||||
onForward,
|
||||
onThread
|
||||
}: {
|
||||
mail: Mail;
|
||||
onRead: () => void;
|
||||
onForward: () => void;
|
||||
onThread: () => void;
|
||||
}) {
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||
|
||||
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
|
||||
//
|
||||
// Agent → `pi@/home/program/agentmail.<会话别名>` 三段才唯一确定
|
||||
// 人 → `jianf` 人没有目录也不需要会话位
|
||||
//
|
||||
// 此前这里有两处错:别名被拼给了**发件人**(`jianf.<别名>` —— 既指错归属,
|
||||
// 又因为人没有工作目录而拼出 path 位为空的非法地址),以及收件人**没有**
|
||||
// 别名(`pi@/home/program/agentmail` 指向默认会话,不是人指定的那条)。
|
||||
//
|
||||
// workspace 取 `session_workspace` 而不是 from/to_workspace:后者对 Agent
|
||||
// 存的是 Agent 名而非路径(历史遗留),拿它拼会得到 `dsh@dsh`。
|
||||
const ws = mail.session_workspace || '';
|
||||
const from = participantAddress(
|
||||
mail.from_name,
|
||||
mail.from_human,
|
||||
mail.from_human ? '' : ws,
|
||||
mail.session_alias
|
||||
);
|
||||
const to = participantAddress(
|
||||
mail.to_name,
|
||||
mail.to_human,
|
||||
mail.to_human ? '' : ws,
|
||||
mail.session_alias
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<BackButton />
|
||||
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2>
|
||||
{mail.status === 'unread' && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
|
||||
未读
|
||||
</span>
|
||||
)}
|
||||
{mail.mail_type === 'permission_request' && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium">
|
||||
<ShieldIcon className="w-3 h-3" />
|
||||
权限请求
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{mail.status === 'unread' && (
|
||||
<button onClick={onRead} className="tap text-xs text-blue-500 hover:underline">
|
||||
标记已读
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onThread}
|
||||
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
title="沿回复与转发关系展开整条线索"
|
||||
>
|
||||
<TreeIcon className="w-3.5 h-3.5" />
|
||||
对话树
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward}
|
||||
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ForwardIcon className="w-3.5 h-3.5" />
|
||||
转发
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl className="text-xs text-gray-500 space-y-0.5">
|
||||
<Row label="发件">{from}</Row>
|
||||
<Row label="收件">{to}</Row>
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<Row label="抄送">
|
||||
{mail.cc_list.map(a => a.raw || `${a.name}@${a.path || ''}`).join('、')}
|
||||
</Row>
|
||||
)}
|
||||
<Row label="时间">{time}</Row>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
|
||||
{/* min-w-0 + break-all:会话别名可达 128 字节,不给收缩权会把整行撑出容器 */}
|
||||
<dd className="min-w-0 font-mono text-gray-600 break-all">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) {
|
||||
// 「这封是我发的吗」而不是「发件人叫 human 吗」:多用户下登录名可能是
|
||||
// jianf,写死 'human' 会让自己发的信显示成机器人图标。
|
||||
const me = useAuthStore(s => s.user?.username || '');
|
||||
const isMine = !!me && mail.from_name === me;
|
||||
const isPermission = mail.mail_type === 'permission_request';
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-4 ${
|
||||
isPermission
|
||||
? 'border-orange-200 bg-orange-50'
|
||||
: isMine
|
||||
? 'border-blue-200 bg-blue-50/60'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-xs">
|
||||
{isMine ? (
|
||||
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
|
||||
) : (
|
||||
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
|
||||
)}
|
||||
{/* 显示真实发件人名而不是 'human':会话里可能有多个人类参与方,
|
||||
都渲染成 human 就分不清谁说的话 */}
|
||||
<span className="font-semibold text-gray-800 font-mono">{mail.from_name}</span>
|
||||
{isPermission && (
|
||||
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
|
||||
权限请求
|
||||
</span>
|
||||
)}
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<span
|
||||
className="text-[10px] text-gray-400"
|
||||
title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')}
|
||||
>
|
||||
抄送 {mail.cc_list.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-gray-400">{time}</span>
|
||||
{onForward && (
|
||||
<button
|
||||
onClick={onForward}
|
||||
title="转发这封"
|
||||
className="text-gray-400 hover:text-blue-600"
|
||||
>
|
||||
<ForwardIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="markdown text-sm">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
|
||||
</div>
|
||||
<AttachmentList items={mail.attachments ?? []} />
|
||||
{isPermission && <PermissionPanel mail={mail} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限请求的决策面板。
|
||||
*
|
||||
* 导出供测试单独渲染:通过整个 MailView 渲染它需要先把 mailStore 与
|
||||
* sessionStore 摆到「当前正看着一封 permission_request 邮件」的状态,
|
||||
* 那些铺垫与这个组件本身的行为无关。
|
||||
*/
|
||||
export function PermissionPanel({ mail }: { mail: Mail }) {
|
||||
const [note, setNote] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [decided, setDecided] = useState(mail.permission_result || '');
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
|
||||
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
|
||||
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
|
||||
|
||||
const decide = async (choice: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.decidePermission(mail.mail_id, choice, note || undefined);
|
||||
setDecided(choice);
|
||||
await fetchInbox('all');
|
||||
if (mail.session_id) selectSession(mail.session_id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (decided) {
|
||||
return (
|
||||
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
|
||||
已处理:<strong className="text-gray-800">{decided}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => decide(opt)}
|
||||
disabled={busy}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
||||
isApprove(opt)
|
||||
? 'bg-green-700 text-white hover:bg-green-800'
|
||||
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
||||
}`}
|
||||
>
|
||||
{isApprove(opt) ? (
|
||||
<CheckIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="备注(可选)"
|
||||
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyBar({
|
||||
replyTo,
|
||||
/**
|
||||
* 会话视图传进来的目标地址,覆盖「按锚点邮件推断」。
|
||||
*
|
||||
* 会话视图的语义是「跟这个 Agent 的一次任务」,对端是会话的属性;
|
||||
* 单封邮件视图没有这层语境,才回落到按那封邮件推断。
|
||||
*/
|
||||
overrideTarget
|
||||
}: {
|
||||
replyTo?: Mail;
|
||||
overrideTarget?: string;
|
||||
}) {
|
||||
const [body, setBody] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
// 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。
|
||||
// 原邮件带抄送时自动展开并预填 —— 「回复全部」是人在这种场景下的默认预期
|
||||
const [ccOpen, setCcOpen] = useState(false);
|
||||
const [budgetEditing, setBudgetEditing] = useState(false);
|
||||
const [budgetBusy, setBudgetBusy] = useState(false);
|
||||
const [maxRoundsDraft, setMaxRoundsDraft] = useState('');
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
const budget = useSessionStore(s => s.budget);
|
||||
const setBudget = useSessionStore(s => s.setBudget);
|
||||
const me = useAuthStore(s => s.user?.username || '');
|
||||
|
||||
if (!replyTo) return null;
|
||||
|
||||
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
|
||||
// 多用户下登录名可能是 jianf,判据恒为假 → 对端取成自己 → 信发给自己。
|
||||
const peer = mailCounterpart(replyTo, me);
|
||||
const target = overrideTarget || mailReplyTarget(replyTo, me);
|
||||
|
||||
const send = async () => {
|
||||
if (!body.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
|
||||
reply_to: replyTo.mail_id,
|
||||
cc: cc.trim(),
|
||||
attachment_ids: attachments.map(a => a.id)
|
||||
});
|
||||
setBody('');
|
||||
setCc('');
|
||||
setCcOpen(false);
|
||||
setAttachments([]);
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
if (replyTo.session_id) selectSession(replyTo.session_id);
|
||||
} catch (err) {
|
||||
// 必须显示出来:预算耗尽、地址不存在、速率限制都会走到这里,
|
||||
// 原先只 console.error,用户点了发送什么反应都没有
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 「回复全部」:把原邮件的其他参与方填进抄送。
|
||||
*
|
||||
* cc_list 是结构化的 Address(后端解析过三维寻址)。传当前会话别名进去,
|
||||
* 让 `.new` 被换成真实别名 —— 原样回填 `.new` 会让这封回复给抄送方
|
||||
* **另开一条新会话**,于是同一件事裂成两条线索。
|
||||
*/
|
||||
const replyAll = () => {
|
||||
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
|
||||
// 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。
|
||||
setCc(replyAllCC(replyTo, me, peer.name).join(', '));
|
||||
setCcOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate">回复 {target}</p>
|
||||
<div className="flex-1" />
|
||||
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={replyAll}
|
||||
className="tap text-[10px] text-gray-500 hover:text-blue-600"
|
||||
>
|
||||
回复全部
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCcOpen(o => !o)}
|
||||
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
|
||||
>
|
||||
{ccOpen ? '收起抄送' : '抄送'}
|
||||
</button>
|
||||
</div>
|
||||
{ccOpen && (
|
||||
<div className="mb-2">
|
||||
<AddressInput
|
||||
value={cc}
|
||||
onChange={setCc}
|
||||
allowMultiple
|
||||
placeholder="抄送:逗号分隔,可多个(如 pi@root, ops@root)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="回复内容(Markdown)"
|
||||
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
|
||||
<div className="flex-1" />
|
||||
{budget && !budgetEditing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMaxRoundsDraft(budget.unlimited ? '' : String(budget.max_rounds));
|
||||
setBudgetEditing(true);
|
||||
}}
|
||||
title="本任务往返预算:Agent 主动发信上限(自动转发与权限询问不占)"
|
||||
className={`tap inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded border ${
|
||||
budget.unlimited
|
||||
? 'border-gray-200 text-gray-500 hover:border-blue-300'
|
||||
: budget.remaining === 0
|
||||
? 'border-red-200 text-red-600'
|
||||
: 'border-gray-200 text-gray-500 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<GaugeIcon className="w-3 h-3" />
|
||||
{budget.unlimited
|
||||
? '预算不限'
|
||||
: `${budget.used_rounds}/${budget.max_rounds} 来回${budget.remaining === 0 ? ' · 已用尽' : ''}`}
|
||||
</button>
|
||||
)}
|
||||
{budget && budgetEditing && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-[10px] text-gray-500">预算</span>
|
||||
<input
|
||||
value={maxRoundsDraft}
|
||||
onChange={e => setMaxRoundsDraft(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
autoFocus
|
||||
className="w-14 text-xs border border-gray-300 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<button
|
||||
disabled={budgetBusy || (maxRoundsDraft.trim() !== '' && !/^\d+$/.test(maxRoundsDraft.trim()))}
|
||||
onClick={async () => {
|
||||
setBudgetBusy(true);
|
||||
await setBudget({ max_rounds: maxRoundsDraft.trim() === '' ? 0 : Number(maxRoundsDraft.trim()) });
|
||||
setBudgetBusy(false);
|
||||
setBudgetEditing(false);
|
||||
}}
|
||||
className="tap px-2 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBudgetEditing(false)}
|
||||
className="tap text-[10px] text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setBody('');
|
||||
setError(null);
|
||||
}}
|
||||
className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={busy || !body.trim()}
|
||||
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? '发送中' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '进行中', cls: 'bg-yellow-100 text-yellow-700' },
|
||||
waiting: { label: '等待中', cls: 'bg-blue-100 text-blue-700' },
|
||||
completed: { label: '已完成', cls: 'bg-green-100 text-green-700' },
|
||||
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
|
||||
};
|
||||
const b = map[status] || map.active;
|
||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
|
||||
}
|
||||
321
client/electron/src/components/ModelScopePanel.tsx
Normal file
321
client/electron/src/components/ModelScopePanel.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { AgentStats, CatalogModel, ModelRoute } from '../api/client';
|
||||
import { CheckIcon, SpinnerIcon, ChevronRightIcon, CpuIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 每个 Agent 平台在**邮件场景**下可用的模型范围。
|
||||
*
|
||||
* 为什么是勾选而不是手打模型名:模型清单是平台侧的事实(opencode 的 provider
|
||||
* 配置、DSH 的 llm 适配器注册),手打就会打错,而打错的后果要到真发邮件时
|
||||
* 才暴露成一次失败。插件随心跳上报它当前看得见的目录,这里只做勾选。
|
||||
*
|
||||
* 顺序即优先级:插件按这个顺序逐个尝试,全部失败才回一封说明失败原因的邮件。
|
||||
* 一个都不选 = 不限定,回退到平台自己的默认模型 —— 与「一个都不许用」不同。
|
||||
*/
|
||||
export default function ModelScopePanel({ agents }: { agents: AgentStats[] }) {
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PanelHeader count={0} />
|
||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PanelHeader count={agents.length} />
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{agents.map(a => (
|
||||
<AgentModelRow
|
||||
key={a.agent_name}
|
||||
agentName={a.agent_name}
|
||||
expanded={expanded === a.agent_name}
|
||||
onToggle={() => setExpanded(expanded === a.agent_name ? null : a.agent_name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ count }: { count: number }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CpuIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent 模型范围</h3>
|
||||
{count > 0 && <span className="text-xs text-gray-400">{count}</span>}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
划定每个平台在邮件场景下可用的模型。勾选顺序即尝试顺序 —— 插件按序降级,
|
||||
全部失败才回一封说明失败原因的邮件。
|
||||
<br />
|
||||
一个都不选 = 不限定,用平台自己的默认模型。清单由插件随心跳上报。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentModelRow({
|
||||
agentName,
|
||||
expanded,
|
||||
onToggle
|
||||
}: {
|
||||
agentName: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<CatalogModel[]>([]);
|
||||
const [stale, setStale] = useState<ModelRoute[]>([]);
|
||||
// picks 是有序的:数组下标就是 rank
|
||||
const [picks, setPicks] = useState<string[]>([]);
|
||||
const [saved, setSaved] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const res = await api.adminGetAgentModels(agentName);
|
||||
setCatalog(res.catalog);
|
||||
setStale(res.stale || []);
|
||||
// 已选项按 rank 排出初始顺序
|
||||
const chosen = res.catalog
|
||||
.filter(m => m.allowed)
|
||||
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
|
||||
.map(keyOf);
|
||||
// 已选但已不在目录里的仍要保留:不显示会让人以为没选过,
|
||||
// 而保存时若把它们丢掉就等于静默改了配置
|
||||
const staleKeys = (res.stale || []).map(keyOf);
|
||||
const all = [...chosen, ...staleKeys.filter(k => !chosen.includes(k))];
|
||||
setPicks(all);
|
||||
setSaved(all);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agentName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) load();
|
||||
}, [expanded, load]);
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setPicks(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]));
|
||||
};
|
||||
|
||||
const move = (key: string, delta: number) => {
|
||||
setPicks(prev => {
|
||||
const i = prev.indexOf(key);
|
||||
const j = i + delta;
|
||||
if (i < 0 || j < 0 || j >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setErr('');
|
||||
try {
|
||||
const res = await api.adminSetAgentModels(agentName, picks.map(parseKey));
|
||||
// 用服务端返回的结果而不是本地 picks:repo 层会跳过重复与空字段,
|
||||
// 直接信本地状态会让界面显示保存成功而实际存下来的不同
|
||||
const persisted = res.models.map(keyOf);
|
||||
setPicks(persisted);
|
||||
setSaved(persisted);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = picks.join('|') !== saved.join('|');
|
||||
const staleKeys = stale.map(keyOf);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap text-left hover:bg-gray-50"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">{agentName}</span>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-gray-500">
|
||||
{saved.length === 0 ? '不限定(用平台默认模型)' : `${saved.length} 个模型,按序尝试`}
|
||||
</span>
|
||||
{staleKeys.length > 0 && (
|
||||
<span
|
||||
className="shrink-0 px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[9px]"
|
||||
title="已选但平台当前没有上报这些模型"
|
||||
>
|
||||
{staleKeys.length} 个已失效
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-3 bg-gray-50 border-t border-gray-100">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 pt-3">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-red-600 pt-3">{err}</p>}
|
||||
|
||||
{!loading && catalog.length === 0 && staleKeys.length === 0 && (
|
||||
<p className="text-[11px] text-gray-500 pt-3">
|
||||
该平台还没有上报模型清单。插件会在心跳时上报(约 30 秒一次)——
|
||||
若长时间为空,检查插件是否在运行、以及它能否读到平台的 provider 配置。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{picks.length > 0 && (
|
||||
<div className="pt-3">
|
||||
<p className="text-[10px] font-medium text-gray-500 mb-1.5">
|
||||
尝试顺序(自上而下)
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{picks.map((key, i) => {
|
||||
const meta = catalog.find(m => keyOf(m) === key);
|
||||
const isStale = !meta && staleKeys.includes(key);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded border bg-white ${
|
||||
isStale ? 'border-amber-200' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 text-[10px] text-gray-400 shrink-0">{i + 1}</span>
|
||||
<span className="text-xs font-mono text-gray-800 truncate">{key}</span>
|
||||
{isStale && (
|
||||
<span
|
||||
className="shrink-0 text-[9px] text-amber-700"
|
||||
title="平台当前没有上报这个模型,可能已下线"
|
||||
>
|
||||
已失效
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => move(key, -1)}
|
||||
disabled={i === 0}
|
||||
title="上移"
|
||||
className="tap shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(key, 1)}
|
||||
disabled={i === picks.length - 1}
|
||||
title="下移"
|
||||
className="tap shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggle(key)}
|
||||
title="移除"
|
||||
className="tap shrink-0 text-gray-400 hover:text-red-600 text-xs px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-gray-500 mb-1.5">
|
||||
平台上报的模型({catalog.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{catalog.map(m => {
|
||||
const key = keyOf(m);
|
||||
const on = picks.includes(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggle(key)}
|
||||
title={m.display_name || key}
|
||||
className={`tap px-2 py-1 text-[11px] font-mono rounded border transition-colors ${
|
||||
on
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(catalog.length > 0 || picks.length > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{picks.length === 0 && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
未选 = 不限定,用平台默认模型
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={() => setPicks(saved)}
|
||||
className="tap text-[11px] text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className="tap inline-flex items-center gap-1 px-3 py-1 text-[11px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** provider/model 拼成一个稳定的键,用于勾选状态与顺序。 */
|
||||
function keyOf(m: { provider: string; model: string }): string {
|
||||
return `${m.provider}/${m.model}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 键拆回 provider 与 model。
|
||||
*
|
||||
* 按**第一个** `/` 切:model id 里可能含 `/`(如 `org/model-name`),
|
||||
* 而 provider id 不含。按最后一个切会把 provider 切错。
|
||||
*/
|
||||
function parseKey(key: string): ModelRoute {
|
||||
const i = key.indexOf('/');
|
||||
if (i < 0) return { provider: key, model: '' };
|
||||
return { provider: key.slice(0, i), model: key.slice(i + 1) };
|
||||
}
|
||||
135
client/electron/src/components/NarrowNav.tsx
Normal file
135
client/electron/src/components/NarrowNav.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { useUIStore, type ViewMode } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
InboxIcon,
|
||||
SentIcon,
|
||||
ContactsIcon,
|
||||
ComposeIcon,
|
||||
UsersIcon,
|
||||
PersonIcon,
|
||||
ShieldIcon,
|
||||
CalendarIcon
|
||||
} from './icons';
|
||||
import { ConnectionIndicator } from './ConnectionIndicator';
|
||||
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
|
||||
|
||||
/**
|
||||
* 窄屏底部导航。
|
||||
*
|
||||
* 移动端把主导航放底部而不是顶部:拇指够得到。
|
||||
* 宽屏用的是左侧竖条(Sidebar),两者共用 uiStore 的 viewMode,
|
||||
* 所以从窄拖到宽不会丢失当前位置。
|
||||
*
|
||||
* 这里只放最常用的几项 + 一个「更多」入口(打开抽屉式 Sidebar)——
|
||||
* 底部塞满图标会挤成一排看不懂的小方块。
|
||||
*/
|
||||
const items: {
|
||||
short: string;
|
||||
mode: ViewMode;
|
||||
Icon: (p: { className?: string }) => JSX.Element;
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{ short: '收件', mode: 'inbox', Icon: InboxIcon },
|
||||
{ short: '授权', mode: 'permissions', Icon: ShieldIcon },
|
||||
{ short: '发件', mode: 'sent', Icon: SentIcon },
|
||||
{ short: '日历', mode: 'calendar', Icon: CalendarIcon },
|
||||
{ short: '联系人', mode: 'contacts', Icon: ContactsIcon },
|
||||
{ short: '管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
|
||||
];
|
||||
|
||||
export default function NarrowNav() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const setViewMode = useUIStore(s => s.setViewMode);
|
||||
const composing = useUIStore(s => s.composing);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
const narrowPane = useUIStore(s => s.narrowPane);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
// 与 Sidebar 同一判据:权限请求归「授权」,不算进收件箱未读
|
||||
const { normal, permissions } = splitByPermission(inbox);
|
||||
const unread = normal.filter(m => m.status === 'unread').length;
|
||||
const pendingPerms = countPendingPermissions(permissions);
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
|
||||
const user = useAuthStore(s => s.user);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const visible = items.filter(n => !n.adminOnly || isAdmin);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="narrow-nav shrink-0 border-t border-chrome-700 bg-chrome-900 flex items-stretch"
|
||||
// 底部安全区:iPhone 的手势条会盖住最后一排
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
{visible.map(({ short, mode, Icon }) => {
|
||||
// 详情栏打开时不高亮任何导航项:此刻用户看的是某封邮件,
|
||||
// 高亮「收件」会让人以为点它能回到列表(其实是同一项)
|
||||
const active = viewMode === mode && !composing && narrowPane === 'list';
|
||||
const badge =
|
||||
mode === 'inbox'
|
||||
? unread
|
||||
: mode === 'permissions'
|
||||
? pendingPerms
|
||||
: mode === 'contacts'
|
||||
? contacts.length
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
active ? 'text-white' : 'text-chrome-400 active:bg-chrome-800'
|
||||
}`}
|
||||
>
|
||||
<Icon />
|
||||
<span className="text-[10px] leading-none">{short}</span>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||
mode === 'inbox'
|
||||
? 'bg-red-600 text-white'
|
||||
: mode === 'permissions'
|
||||
? 'bg-orange-700 text-white'
|
||||
: 'bg-chrome-600 text-chrome-100'
|
||||
}`}
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
{active && <span className="absolute top-0 left-1/4 right-1/4 h-0.5 bg-blue-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => startCompose()}
|
||||
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
|
||||
composing ? 'text-blue-300' : 'text-blue-400 active:bg-chrome-800'
|
||||
}`}
|
||||
>
|
||||
<ComposeIcon />
|
||||
<span className="text-[10px] leading-none">新建</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'text-white'
|
||||
: 'text-chrome-400 active:bg-chrome-800'
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<PersonIcon />
|
||||
<span className="absolute -top-0.5 -right-1.5">
|
||||
<ConnectionIndicator />
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] leading-none">我的</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
11
client/electron/src/components/NarrowOnly.tsx
Normal file
11
client/electron/src/components/NarrowOnly.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
|
||||
/**
|
||||
* 只在窄屏渲染子元素。
|
||||
*
|
||||
* 不用 Tailwind 的 `md:hidden`:那只是视觉隐藏,元素仍在 DOM 与 tab 序列里,
|
||||
* 宽屏用户按 Tab 会聚焦到一个看不见的返回按钮上。
|
||||
*/
|
||||
export default function NarrowOnly({ children }: { children: React.ReactNode }) {
|
||||
return useIsNarrow() ? <>{children}</> : null;
|
||||
}
|
||||
86
client/electron/src/components/NarrowStack.tsx
Normal file
86
client/electron/src/components/NarrowStack.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 窄屏下的「页面覆盖」容器。
|
||||
*
|
||||
* 与分栏的区别:底层页面(列表)始终挂载,详情页从右侧滑入**盖在它上面**。
|
||||
* 这样做的两个实际好处:
|
||||
* - 列表的滚动位置与选中态天然保留 —— 它没被卸载
|
||||
* - 退出动画有东西可播:如果直接卸载再渲染另一个组件,没有任何一帧
|
||||
* 能让旧页面往右滑出去
|
||||
*
|
||||
* 因此这里必须区分「逻辑上是否打开」(open)与「是否还在 DOM 里」(mounted):
|
||||
* 关闭时先播 200ms 滑出动画,动画结束才卸载。
|
||||
*/
|
||||
export default function NarrowStack({
|
||||
base,
|
||||
overlay,
|
||||
open
|
||||
}: {
|
||||
base: React.ReactNode;
|
||||
overlay: React.ReactNode;
|
||||
open: boolean;
|
||||
}) {
|
||||
// mounted:是否在 DOM 里。entered:是否已滑到位(用于触发 transition)
|
||||
const [mounted, setMounted] = useState(open);
|
||||
const [entered, setEntered] = useState(open);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.current !== null) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
// 必须等浏览器至少绘制一帧「在右侧之外」的状态,否则从挂载到
|
||||
// translate-x-0 是同一帧内完成的,transition 不会触发。
|
||||
// 两层 rAF 是跨浏览器最稳的写法(单层在 Safari 上偶尔仍被合帧)。
|
||||
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
setEntered(false);
|
||||
// 与下面的 duration-200 保持一致;提前卸载会把动画切掉半截
|
||||
timer.current = window.setTimeout(() => {
|
||||
setMounted(false);
|
||||
timer.current = null;
|
||||
}, 200);
|
||||
return () => {
|
||||
if (timer.current !== null) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
{/* 底层:始终挂载。打开覆盖层时用 aria-hidden 把它从无障碍树里摘掉,
|
||||
否则屏幕阅读器会读到两层内容。
|
||||
|
||||
`isolate`(isolation: isolate)是必需的:它让底层**自成一个层叠上下文**。
|
||||
不加的后果在日历上实测到过:月视图的星期表头是 `sticky top-0 z-10`,
|
||||
而覆盖层没有 z-index(= auto = 0)—— 两者在同一个层叠上下文里比,
|
||||
`z-10` 赢过 `auto`,于是底层的表头穿透到二级页面之上,把日程内容遮住一条。
|
||||
|
||||
为何不只给覆盖层加 z-10 就完事:那只能治当下这一处。底层是任意业务组件,
|
||||
下一个人在里面写个 `z-20` 就又复现,而这类 bug 只能肉眼看见。
|
||||
isolate 把边界定在容器上,底层写多少 z-index 都出不来。 */}
|
||||
<div className="absolute inset-0 flex isolate" aria-hidden={open ? 'true' : undefined}>
|
||||
{base}
|
||||
</div>
|
||||
|
||||
{mounted && (
|
||||
<div
|
||||
className={`absolute inset-0 z-10 flex bg-white border-l border-gray-200 shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
|
||||
entered ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{overlay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
client/electron/src/components/PermissionChip.tsx
Normal file
80
client/electron/src/components/PermissionChip.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 权限档位徽标 —— 卡片/列表上显示本任务的档位与实际强制力。
|
||||
*
|
||||
* 两个字段必须成对显示:
|
||||
* permission_mode 档位(plan / workspace / full)——「要求什么」
|
||||
* permission_enforcement 强制力(native / advisory)——「平台实际做到了什么」
|
||||
*
|
||||
* 为什么强制力也要上界面:只显示档位会让人以为 plan 档管住了 homeagent,
|
||||
* 而 homeagent 没有工具拦截点、档位只是提示词建议(advisory)。
|
||||
* 差异可见才符合 I-5(失败必须当场可见)。
|
||||
*/
|
||||
|
||||
export interface PermissionChipProps {
|
||||
/** 档位:plan / workspace / full */
|
||||
mode?: string;
|
||||
/** 实际强制力:native / advisory */
|
||||
enforcement?: string;
|
||||
/** 紧凑模式(卡片上用);默认常规(详情页用) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MODE_LABEL: Record<string, string> = {
|
||||
plan: '只读',
|
||||
workspace: '目录内',
|
||||
full: '全权',
|
||||
};
|
||||
|
||||
/** 档位 → 文字说明(tooltip 用) */
|
||||
export function permissionModeHint(mode?: string, enforcement?: string): string {
|
||||
const enforced = enforcement === 'native';
|
||||
switch (mode) {
|
||||
case 'plan':
|
||||
return enforced
|
||||
? 'plan 档:只读。写/改/执行会被平台强制拦下,本档只用来查与想。'
|
||||
: 'plan 档:只读(advisory,平台不强制)。请把结论写在回信里。';
|
||||
case 'full':
|
||||
return 'full 档:全权。工具调用不需额外授权。';
|
||||
case 'workspace':
|
||||
default:
|
||||
return enforced
|
||||
? 'workspace 档:目录内可动,越界需经授权。'
|
||||
: 'workspace 档(advisory,平台不强制)。请把改动限制在工作目录内。';
|
||||
}
|
||||
}
|
||||
|
||||
export default function PermissionChip({ mode, enforcement, compact }: PermissionChipProps) {
|
||||
// 空档位(人→人的信、旧会话)不显示徽标
|
||||
const normalized = mode || '';
|
||||
if (!['plan', 'workspace', 'full'].includes(normalized)) return null;
|
||||
|
||||
const enforced = enforcement === 'native';
|
||||
|
||||
// 配色按档位:plan 用蓝(只读),workspace 用黄(有边界的动),full 用红/橙(全权)
|
||||
const color = normalized === 'plan'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: normalized === 'full'
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
: 'bg-green-50 text-green-700 border-green-200';
|
||||
|
||||
const hint = permissionModeHint(normalized, enforcement);
|
||||
const label = MODE_LABEL[normalized] ?? normalized;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={hint + (enforcement ? `(强制力:${enforcement === 'native' ? '平台强制' : '仅提示' })` : '')}
|
||||
className={`inline-flex items-center gap-1 rounded border font-medium ${color} ${
|
||||
compact ? 'px-1 text-[10px]' : 'px-1.5 text-xs'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{enforced ? (
|
||||
// native:平台强制 —— 实心圆点
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-current" />
|
||||
) : (
|
||||
// advisory:仅提示 —— 空心圆点
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full border border-current" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
260
client/electron/src/components/PermissionList.tsx
Normal file
260
client/electron/src/components/PermissionList.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import type { Mail } from '../types';
|
||||
import { groupPermissions, type PermissionGroup } from '../lib/mailGroups';
|
||||
import { ShieldIcon, ChevronRightIcon, CheckIcon, CloseIcon, BotIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 授权列表:一级是会话,二级是该会话的授权请求。
|
||||
*
|
||||
* 独立于收件箱存在,因为权限请求不是「一封信」而是「一件待办」——
|
||||
* 它的生命周期是「等人点头 → 决策完就作废」,混进收件箱两者互相伤害:
|
||||
* 一次 Agent 任务能产生十几个权限请求(每个被拦下的 bash/write 都是一封),
|
||||
* 把真正需要阅读的来信压到看不见的地方;反过来,人要找「有什么在等我批」
|
||||
* 也得在几十封信里翻。生产实测一个会话独占 17 封权限邮件。
|
||||
*/
|
||||
export default function PermissionList() {
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
const currentMail = useMailStore(s => s.currentMail);
|
||||
const selectMail = useMailStore(s => s.selectMail);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const clearSession = useSessionStore(s => s.clearSession);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const showDetail = useUIStore(s => s.showDetail);
|
||||
|
||||
const groups = groupPermissions(inbox);
|
||||
const pendingTotal = groups.reduce((n, g) => n + g.pending.length, 0);
|
||||
|
||||
// 有待决策请求的会话默认展开:那些是在等人动手的,藏起来等于没解决问题。
|
||||
// 全部已决策的会话默认折叠 —— 它们只是历史。
|
||||
const [expanded, setExpanded] = useState<Set<string> | null>(null);
|
||||
const autoOpen = groups.filter(g => g.pending.length > 0).map(g => g.sessionId);
|
||||
const openSet = expanded ?? new Set(autoOpen);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInbox('all');
|
||||
}, []);
|
||||
|
||||
const toggle = (sessionId: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev ?? autoOpen);
|
||||
if (next.has(sessionId)) next.delete(sessionId);
|
||||
else next.add(sessionId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const pick = (m: Mail) => {
|
||||
clearSession();
|
||||
cancelCompose();
|
||||
selectMail(m);
|
||||
showDetail();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[340px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">授权</h2>
|
||||
{pendingTotal > 0 ? (
|
||||
<span className="ml-2 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-700 text-[10px] font-semibold">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{pendingTotal} 待决策
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{groups.length > 0 ? `${groups.length} 个会话` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{groups.map(g => (
|
||||
<PermissionSessionGroup
|
||||
key={g.sessionId}
|
||||
group={g}
|
||||
open={openSet.has(g.sessionId)}
|
||||
onToggle={() => toggle(g.sessionId)}
|
||||
currentMailID={currentMail?.mail_id}
|
||||
onPick={pick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{groups.length === 0 && (
|
||||
<div className="text-center py-10">
|
||||
<ShieldIcon className="w-8 h-8 mx-auto text-gray-300" />
|
||||
<p className="text-xs text-gray-400 mt-2">没有授权请求</p>
|
||||
<p className="text-[10px] text-gray-400 mt-1 px-6">
|
||||
Agent 执行敏感操作(bash、写文件)时会在这里请求你批准
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个会话的授权组:组头显示 Agent 与待决策数,展开后是逐条请求。 */
|
||||
function PermissionSessionGroup({
|
||||
group: g,
|
||||
open,
|
||||
onToggle,
|
||||
currentMailID,
|
||||
onPick
|
||||
}: {
|
||||
group: PermissionGroup;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
currentMailID?: string;
|
||||
onPick: (m: Mail) => void;
|
||||
}) {
|
||||
const [showSettled, setShowSettled] = useState(false);
|
||||
const hasPending = g.pending.length > 0;
|
||||
const time = new Date(g.latest.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border ${
|
||||
hasPending ? 'border-orange-200 bg-orange-50/50' : 'border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onToggle} className="w-full text-left px-3 py-2.5 rounded-lg">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${
|
||||
open ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
<BotIcon className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
||||
<span className="text-xs font-mono text-gray-900 truncate">
|
||||
{g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-1 pl-5">
|
||||
{hasPending ? (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-orange-700 text-white text-[9px] font-bold">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{g.pending.length} 待决策
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]">
|
||||
<CheckIcon className="w-2.5 h-2.5" />
|
||||
已全部处理
|
||||
</span>
|
||||
)}
|
||||
{g.settled.length > 0 && (
|
||||
<span className="text-[10px] text-gray-400">历史 {g.settled.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!g.alias && (
|
||||
<p className="text-[10px] text-gray-400 font-mono truncate mt-0.5 pl-5">
|
||||
(未命名会话)
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-2 pb-2 space-y-0.5">
|
||||
{g.pending.map(m => (
|
||||
<PermissionRow
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
onClick={() => onPick(m)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{g.settled.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowSettled(v => !v)}
|
||||
className="w-full text-left px-2 py-1 text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showSettled ? '收起' : '展开'}已决策 {g.settled.length}
|
||||
</button>
|
||||
{showSettled &&
|
||||
g.settled.map(m => (
|
||||
<PermissionRow
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
onClick={() => onPick(m)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一条授权请求。待决策的醒目,已决策的连结果一起显示(批了还是拒了)。 */
|
||||
function PermissionRow({
|
||||
mail,
|
||||
active,
|
||||
onClick
|
||||
}: {
|
||||
mail: Mail;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const settled = !!mail.permission_result;
|
||||
const approved = settled && /同意|允许|批准|approve|yes/i.test(mail.permission_result || '');
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-2.5 py-2 rounded-md border transition-colors ${
|
||||
active
|
||||
? 'bg-blue-50 border-blue-200'
|
||||
: settled
|
||||
? 'border-transparent hover:bg-gray-50'
|
||||
: 'border-orange-200 bg-white hover:bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-xs truncate flex-1 ${
|
||||
settled ? 'text-gray-500' : 'font-medium text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{settled ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] ${
|
||||
approved ? 'text-green-600' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{approved ? <CheckIcon className="w-2.5 h-2.5" /> : <CloseIcon className="w-2.5 h-2.5" />}
|
||||
{mail.permission_result}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-orange-600 font-medium">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
等待你决策
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
231
client/electron/src/components/QuotaPanel.tsx
Normal file
231
client/electron/src/components/QuotaPanel.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { BotIcon, CheckIcon, ArchiveIcon } from './icons';
|
||||
|
||||
/**
|
||||
* Agent 管理面板(管理员):默认预算 + 停用/恢复。
|
||||
*
|
||||
* 预算决定「派给某个 Agent 的新任务默认多少个来回」。
|
||||
* 停用/恢复控制 Agent 的准入:停用后密钥撤销、注册被拒,邮件与会话保留。
|
||||
*/
|
||||
export default function QuotaPanel() {
|
||||
const [stats, setStats] = useState<api.AgentStats[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<'toggle' | 'delete' | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.adminListAgentStats();
|
||||
setStats(r.quotas);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const apply = async (name: string, defaultRounds: number) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminSetDefaultRounds(name, defaultRounds);
|
||||
await load();
|
||||
setDrafts(d => {
|
||||
const next = { ...d };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAgent = async (name: string) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await api.adminDeleteAgent(name);
|
||||
await load();
|
||||
setConfirming(null);
|
||||
setConfirmAction(null);
|
||||
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
|
||||
? `(已撤销 ${result.keys_revoked} 把密钥)`
|
||||
: '';
|
||||
setNotice(`${name} 已删除${revoked}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (name: string, currentDisabled: boolean) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await api.adminSetAgentStatus(name, !currentDisabled);
|
||||
await load();
|
||||
setConfirming(null);
|
||||
// 撤销了几把密钥是停用操作里最有信息量的部分 —— 恢复后要重新签发几把,
|
||||
// 只说「已停用」的话用户不知道还有这一步。
|
||||
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
|
||||
? `(已撤销 ${result.keys_revoked} 把密钥)`
|
||||
: '';
|
||||
// 恢复路径必须把「密钥不会自动回来」说出口。否则用户点完恢复就算完事,
|
||||
// 而插件拿着已撤销的密钥无限重试并被 401 —— 本会话就踩过:
|
||||
// opencode 被停用后拿旧密钥重试了 18 小时,gateway 日志里 2690 次 401。
|
||||
setNotice(result.disabled
|
||||
? `${name} 已停用${revoked}`
|
||||
: `${name} 已恢复为离线。密钥不会自动回来 —— 请到「密钥」面板重新签发一把并写进插件配置,否则它会一直被拒(401)。`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (s: api.AgentStats) => {
|
||||
const st = s.status ?? 'offline';
|
||||
const cls = st === 'online'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: st === 'disabled'
|
||||
? 'bg-red-50 text-red-600'
|
||||
: 'bg-gray-50 text-gray-500';
|
||||
const label = st === 'online' ? '在线' : st === 'disabled' ? '已停用' : '离线';
|
||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${cls}`}>{label}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BotIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent 管理</h3>
|
||||
<span className="text-xs text-gray-400">{stats.length}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
<strong className="font-medium text-gray-600">默认预算</strong>:派给某个 Agent 的新任务默认多少个来回(填 0 = 不限)。
|
||||
这只是默认值 —— 写信时可以单独指定,之后在对话页里还能随时调整。
|
||||
插件自动转发的最终总结与权限询问不占用预算。
|
||||
<br />
|
||||
<strong className="font-medium text-gray-600">停用</strong>:撤销该 Agent 的全部密钥、
|
||||
从地址补全与联系人里隐藏,拒绝它重新注册,并让别人发信给它时收到 409。
|
||||
<span className="text-gray-400">邮件、会话、模型范围全部保留,随时可恢复 —— 但密钥不会自动回来,恢复后需重新签发。</span>
|
||||
<br />
|
||||
<strong className="font-medium text-gray-600">删除</strong>:在停用的基础上清掉运行态(日历事件置 cancelled)。
|
||||
<span className="text-gray-400">邮件与会话保留(历史是审计凭据),此名字今后不可再注册。</span>
|
||||
</p>
|
||||
|
||||
{error && <div className="text-[11px] text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>}
|
||||
{notice && <div className="text-[11px] text-green-700 bg-green-50 rounded px-2 py-1.5">{notice}</div>}
|
||||
|
||||
{stats.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{stats.map(s => {
|
||||
const draft = drafts[s.agent_name] ?? String(s.default_rounds);
|
||||
const dirty = draft !== String(s.default_rounds);
|
||||
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||||
const isDisabled = s.status === 'disabled';
|
||||
const isConfirming = confirming === s.agent_name;
|
||||
|
||||
return (
|
||||
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
|
||||
{s.agent_name}
|
||||
</span>
|
||||
|
||||
{statusBadge(s)}
|
||||
|
||||
<div className="min-w-0 flex-1 text-[11px] text-gray-500">
|
||||
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
|
||||
<span className="text-gray-400">
|
||||
{' · '}进行中 {s.active_sessions} 个任务 · 累计发信 {s.sent_total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDrafts(d => ({ ...d, [s.agent_name]: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
title="新任务默认往返数;0 = 不限"
|
||||
className={`w-16 text-xs border rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
invalid ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => apply(s.agent_name, Number(draft.trim() || '0'))}
|
||||
disabled={!dirty || invalid || busy === s.agent_name}
|
||||
title="保存默认预算"
|
||||
className="tap shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 停用/恢复/删除 按钮 */}
|
||||
{isConfirming ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-gray-500">确认{confirmAction === 'delete' ? '删除' : ''}?</span>
|
||||
<button
|
||||
onClick={() => confirmAction === 'delete'
|
||||
? deleteAgent(s.agent_name)
|
||||
: toggleStatus(s.agent_name, isDisabled)}
|
||||
disabled={busy === s.agent_name}
|
||||
className="tap text-[10px] px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100"
|
||||
>
|
||||
{confirmAction === 'delete' ? '删除' : (isDisabled ? '恢复' : '停用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfirming(null); setConfirmAction(null); }}
|
||||
className="tap text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { setConfirming(s.agent_name); setConfirmAction('toggle'); }}
|
||||
disabled={busy === s.agent_name}
|
||||
title={isDisabled
|
||||
? '恢复此 Agent(恢复为离线;停用时撤销的密钥不会自动回来,必须重新签发)'
|
||||
: '停用此 Agent(撤销全部密钥并从补全里隐藏;邮件与会话保留,可恢复)'}
|
||||
className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${
|
||||
isDisabled
|
||||
? 'border-green-200 text-green-700 hover:bg-green-50'
|
||||
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
|
||||
} disabled:opacity-30`}
|
||||
>
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
{isDisabled ? '恢复' : '停用'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfirming(s.agent_name); setConfirmAction('delete'); }}
|
||||
disabled={busy === s.agent_name}
|
||||
title="彻底删除此 Agent(清除密钥与运行态;邮件保留但此名今后不可再用)"
|
||||
className="tap shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-red-200 text-red-400 hover:text-red-600 hover:border-red-300 disabled:opacity-30"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
client/electron/src/components/SetupPage.tsx
Normal file
140
client/electron/src/components/SetupPage.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { MailboxIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/** 首次初始化向导:系统无任何用户时展示,创建首个管理员 */
|
||||
export default function SetupPage({ onDone }: { onDone: () => void }) {
|
||||
const bootstrap = useAuthStore(s => s.bootstrap);
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const mismatch = confirm !== '' && password !== confirm;
|
||||
const ok =
|
||||
username.trim().length >= 2 &&
|
||||
password.length >= 8 &&
|
||||
!mismatch &&
|
||||
!busy;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setupAdmin({
|
||||
username: username.trim().toLowerCase(),
|
||||
password,
|
||||
display_name: displayName.trim()
|
||||
});
|
||||
// 初始化后直接登录(后端已经种了 cookie),拉取用户态
|
||||
await bootstrap();
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 卡片高约 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-[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" />
|
||||
</div>
|
||||
<h1 className="mt-3 text-base font-semibold text-gray-900">初始化系统</h1>
|
||||
<p className="mt-1 text-xs text-gray-500 text-center">
|
||||
这是系统首次启动。创建一个管理员账号以开始使用 AgentMail。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">
|
||||
管理员用户名 <span className="text-red-400">(即三维地址的 name 位)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
spellCheck={false}
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-400">
|
||||
小写字母数字 . _ -,2-64 位;后续可以 `admin@.new` 形式作为收件人
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">显示名</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
placeholder="系统管理员"
|
||||
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">
|
||||
密码 <span className="text-red-400">(至少 8 位)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="new-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>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">确认密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={`w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次输入的密码不一致</p>}
|
||||
</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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ok}
|
||||
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"
|
||||
>
|
||||
{busy && <SpinnerIcon className="w-3.5 h-3.5" />}
|
||||
{busy ? '初始化中' : '创建管理员并进入'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
client/electron/src/components/Sidebar.tsx
Normal file
143
client/electron/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { useUIStore, type ViewMode } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
InboxIcon,
|
||||
SentIcon,
|
||||
ContactsIcon,
|
||||
ComposeIcon,
|
||||
UsersIcon,
|
||||
LogoutIcon,
|
||||
ShieldIcon,
|
||||
CalendarIcon
|
||||
} from './icons';
|
||||
import { ConnectionIndicator } from './ConnectionIndicator';
|
||||
import { ThemeToggleButton } from './ThemePicker';
|
||||
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
|
||||
|
||||
const navItems: {
|
||||
short: string;
|
||||
title: string;
|
||||
mode: ViewMode;
|
||||
Icon: (p: { className?: string }) => JSX.Element;
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
|
||||
// 授权紧跟收件箱:它是收件箱的「要动手」那一半,放在联系人之后会让人找不到
|
||||
{ short: '授权', title: '授权请求', mode: 'permissions', Icon: ShieldIcon },
|
||||
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
|
||||
// 日历排在联系人之前:它是「我要安排什么」,与收发信同属日常动作;
|
||||
// 联系人是「查谁在哪」,用得少
|
||||
{ short: '日历', title: '日历', mode: 'calendar', Icon: CalendarIcon },
|
||||
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
|
||||
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const setViewMode = useUIStore(s => s.setViewMode);
|
||||
const composing = useUIStore(s => s.composing);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
// 收件箱的未读数只算普通邮件:权限请求归「授权」那一项,
|
||||
// 两处都数会让一个待批的 bash 在界面上显示成两件事
|
||||
const { normal, permissions } = splitByPermission(inbox);
|
||||
const unread = normal.filter(m => m.status === 'unread').length;
|
||||
const pendingPerms = countPendingPermissions(permissions);
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
|
||||
const user = useAuthStore(s => s.user);
|
||||
const logout = useAuthStore(s => s.logout);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-chrome-900"
|
||||
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{navItems
|
||||
.filter(n => !n.adminOnly || isAdmin)
|
||||
.map(({ short, title, mode, Icon }) => {
|
||||
const active = viewMode === mode && !composing;
|
||||
const badge =
|
||||
mode === 'inbox'
|
||||
? unread
|
||||
: mode === 'permissions'
|
||||
? pendingPerms
|
||||
: mode === 'contacts'
|
||||
? contacts.length
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
title={title}
|
||||
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
active
|
||||
? 'bg-chrome-700 text-white'
|
||||
: 'text-chrome-400 hover:bg-chrome-800 hover:text-chrome-100'
|
||||
}`}
|
||||
>
|
||||
<Icon />
|
||||
<span className="text-[9px] leading-none">{short}</span>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||
mode === 'inbox'
|
||||
? 'bg-red-600 text-white'
|
||||
: mode === 'permissions'
|
||||
? // 待决策的授权用橙色:它跟未读不是一类紧急 ——
|
||||
// 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」
|
||||
'bg-orange-700 text-white'
|
||||
: 'bg-chrome-600 text-chrome-100'
|
||||
}`}
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={() => startCompose()}
|
||||
title="新建邮件"
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
|
||||
composing ? 'bg-blue-700 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
<ComposeIcon />
|
||||
<span className="text-[9px] leading-none">新建</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-chrome-700">
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'
|
||||
}`}
|
||||
>
|
||||
{(user?.display_name || user?.username || '?').slice(0, 2)}
|
||||
{/* 连接状态点:不遮挡文字,贴在右下角 */}
|
||||
<span className="absolute -bottom-0.5 -right-0.5">
|
||||
<ConnectionIndicator />
|
||||
</span>
|
||||
</button>
|
||||
<ThemeToggleButton className="w-9 h-7 rounded flex items-center justify-center text-chrome-400 hover:text-white hover:bg-chrome-800" />
|
||||
<button
|
||||
onClick={logout}
|
||||
title="退出登录"
|
||||
className="w-9 h-7 rounded flex items-center justify-center text-chrome-400 hover:text-white hover:bg-chrome-800"
|
||||
>
|
||||
<LogoutIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
client/electron/src/components/ThemePicker.tsx
Normal file
83
client/electron/src/components/ThemePicker.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useThemeStore, type ThemePref } from '../stores/themeStore';
|
||||
import { SunIcon, MoonIcon, MonitorIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 主题切换。
|
||||
*
|
||||
* 两种形态共用一份状态:
|
||||
* - `compact`(侧栏 / 底部导航):单按钮,点一下翻转
|
||||
* - 默认(「我的」页):三选一,因为 `system` 只有在能明确选中时才有意义
|
||||
*
|
||||
* 单按钮不足以表达三态,但侧栏放不下三个选项;而只给单按钮的话
|
||||
* 用户一旦点过就永久脱离了「跟随系统」—— 那是个回不去的单向门。
|
||||
* 所以两个入口都提供,compact 用于快速切换,完整形态用于设定偏好。
|
||||
*/
|
||||
|
||||
const OPTIONS: { value: ThemePref; label: string; hint: string; Icon: (p: { className?: string }) => JSX.Element }[] = [
|
||||
{ value: 'light', label: '浅色', hint: '始终使用浅色', Icon: SunIcon },
|
||||
{ value: 'dark', label: '深色', hint: '始终使用深色', Icon: MoonIcon },
|
||||
{ value: 'system', label: '跟随系统', hint: '随系统的深浅色设置切换', Icon: MonitorIcon }
|
||||
];
|
||||
|
||||
/** 侧栏用的单按钮:点一下在浅/深之间翻转。 */
|
||||
export function ThemeToggleButton({ className = '' }: { className?: string }) {
|
||||
const resolved = useThemeStore(s => s.resolved);
|
||||
const pref = useThemeStore(s => s.pref);
|
||||
const toggle = useThemeStore(s => s.toggle);
|
||||
|
||||
const dark = resolved === 'dark';
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
title={
|
||||
pref === 'system'
|
||||
? `跟随系统(当前${dark ? '深色' : '浅色'})—— 点击固定为${dark ? '浅色' : '深色'}`
|
||||
: `当前${dark ? '深色' : '浅色'} —— 点击切换`
|
||||
}
|
||||
aria-label="切换主题"
|
||||
className={className}
|
||||
>
|
||||
{dark ? <MoonIcon className="w-3.5 h-3.5" /> : <SunIcon className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「我的」页用的三选一。 */
|
||||
export default function ThemePicker() {
|
||||
const pref = useThemeStore(s => s.pref);
|
||||
const resolved = useThemeStore(s => s.resolved);
|
||||
const setPref = useThemeStore(s => s.setPref);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">外观</h3>
|
||||
{pref === 'system' && (
|
||||
<span className="text-xs text-gray-500">
|
||||
当前跟随系统:{resolved === 'dark' ? '深色' : '浅色'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{OPTIONS.map(o => {
|
||||
const active = pref === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => setPref(o.value)}
|
||||
title={o.hint}
|
||||
className={`px-3 py-2.5 rounded border text-xs flex flex-col items-center gap-1.5 transition-colors ${
|
||||
active
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<o.Icon className="w-4 h-4" />
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
client/electron/src/components/ThreadView.tsx
Normal file
299
client/electron/src/components/ThreadView.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { participantAddress } from '../lib/replyTarget';
|
||||
import type { ThreadNode } from '../types';
|
||||
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
import BackButton from './BackButton';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
|
||||
/**
|
||||
* 对话树视图(从线索根整树展开,分块加载)。
|
||||
*
|
||||
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
|
||||
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
|
||||
*
|
||||
* 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」
|
||||
* 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件
|
||||
* 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的
|
||||
* 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。
|
||||
*
|
||||
* 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。
|
||||
*
|
||||
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
|
||||
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
|
||||
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
|
||||
*/
|
||||
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
|
||||
const [nodes, setNodes] = useState<ThreadNode[]>([]);
|
||||
const [hidden, setHidden] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [nextOffset, setNextOffset] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
const [initial, setInitial] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinel = useRef<HTMLDivElement>(null);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
|
||||
const gen = useRef(0);
|
||||
// loading 的同步副本。setState 是异步的,哨兵连续进入视口时
|
||||
// 读 state 会看到旧的 false 而并发发两个请求。
|
||||
const busy = useRef(false);
|
||||
|
||||
const sortNodes = (list: ThreadNode[]) =>
|
||||
list
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
a.depth - b.depth || a.created_at.localeCompare(b.created_at)
|
||||
);
|
||||
|
||||
const merge = useCallback((incoming: ThreadNode[]) => {
|
||||
setNodes(prev => {
|
||||
const seen = new Set(prev.map(n => n.mail_id));
|
||||
return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 首屏
|
||||
useEffect(() => {
|
||||
const myGen = ++gen.current;
|
||||
setNodes([]);
|
||||
setHidden(0);
|
||||
setErr('');
|
||||
setInitial(true);
|
||||
busy.current = true;
|
||||
api
|
||||
.getMailThread(mailID, { offset: 0, limit: 60 })
|
||||
.then(p => {
|
||||
if (gen.current !== myGen) return;
|
||||
setNodes(sortNodes(p.nodes));
|
||||
setHidden(p.hidden);
|
||||
setHasMore(p.has_more);
|
||||
setNextOffset(p.next_offset);
|
||||
})
|
||||
.catch(e => {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (gen.current === myGen) {
|
||||
setInitial(false);
|
||||
busy.current = false;
|
||||
}
|
||||
});
|
||||
}, [mailID]);
|
||||
|
||||
// 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后,
|
||||
// 不滚过去的话用户点开一封邮件却停在整条线索的开头。
|
||||
useEffect(() => {
|
||||
if (initial || !anchorRef.current) return;
|
||||
anchorRef.current.scrollIntoView({ block: 'center' });
|
||||
}, [initial]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (busy.current || !hasMore) return;
|
||||
const myGen = gen.current;
|
||||
busy.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 });
|
||||
if (gen.current !== myGen) return;
|
||||
merge(p.nodes);
|
||||
setHidden(h => h + p.hidden);
|
||||
setHasMore(p.has_more);
|
||||
setNextOffset(p.next_offset);
|
||||
} catch (e) {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (gen.current === myGen) setLoading(false);
|
||||
busy.current = false;
|
||||
}
|
||||
}, [mailID, merge, hasMore, nextOffset]);
|
||||
|
||||
// 底部哨兵进入视口就续取。rootMargin 提前 200px 触发,
|
||||
// 让加载在用户滑到边界前完成。
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const obs = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) if (e.isIntersecting) loadMore();
|
||||
},
|
||||
{ root, rootMargin: '200px' }
|
||||
);
|
||||
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
|
||||
return () => obs.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
{/* 窄屏下对话树是盖在列表上的一层,得有返回出口。
|
||||
它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表,
|
||||
关闭只收起树、留在这封邮件上。 */}
|
||||
<BackButton />
|
||||
<span className="text-sm font-semibold text-gray-900">对话树</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
已加载 {nodes.length} 封
|
||||
{hasMore && ',滑动加载更多'}
|
||||
{hidden > 0 && `,${hidden} 封无权查看`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
{initial && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-red-600">{err}</p>}
|
||||
|
||||
{!initial && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
{nodes.map(n => (
|
||||
<Node
|
||||
key={n.mail_id}
|
||||
node={n}
|
||||
anchorID={mailID}
|
||||
anchorRef={n.mail_id === mailID ? anchorRef : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="tap w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
|
||||
>
|
||||
加载后续往来
|
||||
</button>
|
||||
)}
|
||||
<div ref={bottomSentinel} className="h-px" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({
|
||||
node,
|
||||
anchorID,
|
||||
anchorRef
|
||||
}: {
|
||||
node: ThreadNode;
|
||||
anchorID: string;
|
||||
anchorRef?: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const openMailByID = useMailStore(s => s.openMailByID);
|
||||
const narrow = useIsNarrow();
|
||||
const isPermission = node.mail_type === 'permission_request';
|
||||
const isAnchor = node.mail_id === anchorID;
|
||||
const ccCount = node.cc_list?.length ?? 0;
|
||||
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
|
||||
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
|
||||
const isForward = node.subject.startsWith('Fwd: ');
|
||||
// 缩进:每级的像素数与上限都随屏宽变。
|
||||
//
|
||||
// 原先固定「每级 20px、上限 8 级」= 最多 160px。在 320px 屏上容器还要去掉
|
||||
// px-4 的 32px 与连接线的 18px,卡片只剩 110px —— 发件人一行就被 truncate 吃掉。
|
||||
// 窄屏改成每级 10px、上限 5 级(最多 50px),层级仍然看得出来,卡片还有余地。
|
||||
const step = narrow ? 10 : 20;
|
||||
const maxDepth = narrow ? 5 : 8;
|
||||
const indent = Math.min(Math.max(node.depth, 0), maxDepth) * step;
|
||||
const time = new Date(node.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={anchorRef} className="flex items-stretch" style={{ paddingLeft: indent }}>
|
||||
{indent > 0 && (
|
||||
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => openMailByID(node.mail_id)}
|
||||
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
|
||||
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{node.from_workspace ? (
|
||||
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
{/* 树节点一行里塞了 from → to、转发标记与时间,不带会话位:
|
||||
整棵树本来就在同一条线索上,每个节点重复一遍别名毫无信息量。
|
||||
人还是 Agent 走显式布尔;workspace 从会话取(from_workspace
|
||||
对 Agent 存的是 Agent 名)。 */}
|
||||
<span className="text-xs font-mono text-gray-700 truncate">
|
||||
{participantAddress(
|
||||
node.from_name,
|
||||
node.from_human,
|
||||
node.from_human ? '' : node.session_workspace || ''
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">→</span>
|
||||
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
|
||||
<div className="flex-1" />
|
||||
{isForward && (
|
||||
<span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]">
|
||||
转发
|
||||
</span>
|
||||
)}
|
||||
{node.parent_hidden && (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
|
||||
title="上一封不在你的可见范围内"
|
||||
>
|
||||
上游不可见
|
||||
</span>
|
||||
)}
|
||||
{isPermission && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
</span>
|
||||
)}
|
||||
{node.attachment_count > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||||
{node.attachment_count}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
|
||||
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
|
||||
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
|
||||
{ccCount > 0 && (
|
||||
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
|
||||
抄送 {node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')}
|
||||
</p>
|
||||
)}
|
||||
{node.body_preview && (
|
||||
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
|
||||
)}
|
||||
{node.session_alias && (
|
||||
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
client/electron/src/components/WorkCard.tsx
Normal file
150
client/electron/src/components/WorkCard.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import type { Contact } from '../types';
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ComposeIcon,
|
||||
ChevronRightIcon,
|
||||
GaugeIcon,
|
||||
PersonIcon,
|
||||
BotIcon
|
||||
} from './icons';
|
||||
import PermissionChip from './PermissionChip';
|
||||
|
||||
/**
|
||||
* 工作卡片:中间栏的另一种呈现。
|
||||
*
|
||||
* 与列表行(ContactPanel 的 ContactRow)的分工:
|
||||
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」。
|
||||
* 一条线索是一件正在进行的工作,卡片上要能直接看出:
|
||||
* - 主题(多由 Agent 平台的模型生成的摘要)
|
||||
* - 最新一封说了什么、谁说的
|
||||
* - 往返预算还剩多少 —— 预算是任务的属性,快跑满的任务需要人介入
|
||||
*
|
||||
* 容器(列表/滚动/空态)由 ContactPanel 负责:两种视图共用同一份数据与同一套
|
||||
* 打开/写信/归档动作,只有单项的渲染不同。竖向堆叠而非网格 ——
|
||||
* 卡片在中间栏里,320~400px 放不下多列。
|
||||
*/
|
||||
export function WorkCard({
|
||||
contact: c,
|
||||
active,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onArchive
|
||||
}: {
|
||||
contact: Contact;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onCompose: () => void;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const time = new Date(c.last_activity).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const fromHuman = c.last_from !== c.agent_name;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex flex-col rounded-lg border bg-white transition-colors ${
|
||||
active ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onOpen} className="flex-1 text-left p-3 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
|
||||
{c.unread_count > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{c.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-600 font-mono truncate">
|
||||
{c.session_alias || '(未命名会话)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 主题是这张卡片的主角:它回答「这条线索在干什么」 */}
|
||||
<p className="text-xs text-gray-800 mt-1.5 line-clamp-2 leading-snug">
|
||||
{c.subject || '(无主题)'}
|
||||
</p>
|
||||
|
||||
{c.last_preview && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
{fromHuman ? (
|
||||
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<BotIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<p className="text-[11px] text-gray-500 line-clamp-2 leading-snug">
|
||||
{c.last_preview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{c.mail_count} 封 · {time}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<PermissionChip mode={c.permission_mode} enforcement={c.permission_enforcement} compact />
|
||||
<BudgetChip max={c.max_rounds} used={c.used_rounds} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="reveal flex gap-1 px-3 pb-2.5">
|
||||
<button
|
||||
onClick={onCompose}
|
||||
title="写信给该地址"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
<ComposeIcon className="w-3 h-3" />
|
||||
写信
|
||||
</button>
|
||||
<button
|
||||
onClick={onArchive}
|
||||
title="归档该 name@path.session"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300"
|
||||
>
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算指示条。
|
||||
*
|
||||
* 0 = 不限,此时不显示 —— 一个「0/0」或「不限」的徽标对每张卡片都成立,
|
||||
* 等于纯噪声。只在真正设了上限时才占位置。
|
||||
* 剩 1 个来回时转红:那是需要人介入的时刻(要么加预算,要么让它收尾)。
|
||||
*
|
||||
* 导出供测试单独渲染 —— 它是纯展示件,而通过 WorkCard 渲染要先造一整个 Contact。
|
||||
*/
|
||||
export function BudgetChip({ max, used }: { max: number; used: number }) {
|
||||
if (!max || max <= 0) return null;
|
||||
|
||||
const remaining = Math.max(max - used, 0);
|
||||
const tone =
|
||||
remaining === 0
|
||||
? 'bg-red-100 text-red-700'
|
||||
: remaining <= 1
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-gray-100 text-gray-500';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium shrink-0 ${tone}`}
|
||||
title={`往返预算:已用 ${used}/${max}${remaining === 0 ? '(已用尽)' : ''}`}
|
||||
>
|
||||
<GaugeIcon className="w-2.5 h-2.5" />
|
||||
{remaining}/{max}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
413
client/electron/src/components/icons.tsx
Normal file
413
client/electron/src/components/icons.tsx
Normal file
@ -0,0 +1,413 @@
|
||||
// 纯 SVG 图标,全站不使用 emoji
|
||||
type P = { className?: string };
|
||||
|
||||
const D = 'w-5 h-5';
|
||||
|
||||
function Svg({ className = D, children }: P & { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SentIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactsIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComposeIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShieldIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="18" height="10" x="3" y="11" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<path d="M8 16h.01" />
|
||||
<path d="M16 16h.01" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchiveIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="20" height="5" x="2" y="3" rx="1" />
|
||||
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
|
||||
<path d="M10 12h4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersIcon({ className = D }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LockIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailboxIcon({ className = 'w-8 h-8' }: P) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpinnerIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.25" />
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.7 12.3 21 2" />
|
||||
<path d="m17 6 3 3" />
|
||||
<path d="m14 9 3 3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="12" height="12" x="9" y="9" rx="2" />
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForwardIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m15 17 5-5-5-5" />
|
||||
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaperclipIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M13.2 2.8a5 5 0 0 1 7 7l-8.5 8.5a3.2 3.2 0 0 1-4.5-4.5l8-8a1.4 1.4 0 0 1 2 2l-7.5 7.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 11 5 5 5-5" />
|
||||
<path d="M4 20h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 3v5h5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||
<rect x="3" y="17" width="6" height="4" rx="1" />
|
||||
<rect x="15" y="17" width="6" height="4" rx="1" />
|
||||
<path d="M12 7v4M6 17v-3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0l-7.2-7.2A2 2 0 0 1 3 12V4a1 1 0 0 1 1-1h8a2 2 0 0 1 1.4.6l7.2 7.2a2 2 0 0 1 0 2.8Z" />
|
||||
<circle cx="7.5" cy="7.5" r="1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GaugeIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 14 15.5 9" />
|
||||
<path d="M3.5 17a9 9 0 1 1 17 0" />
|
||||
<circle cx="12" cy="14" r="1.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListViewIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardViewIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="18" height="6" rx="1.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CpuIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepeatIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M17 2l4 4-4 4" />
|
||||
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
|
||||
<path d="M7 22l-4-4 4-4" />
|
||||
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="M17 8l-5-5-5 5M12 3v12" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PauseIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M5 3l14 9-14 9V3z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitorIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="2" y="4" width="20" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user