feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
This commit is contained in:
12
web/index.html
Normal file
12
web/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AgentMail</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4228
web/package-lock.json
generated
Normal file
4228
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
web/package.json
Normal file
30
web/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "agentmail-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node test/markdown-xss.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
165
web/src/App.tsx
Normal file
165
web/src/App.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useEffect } from 'react';
|
||||
import { connectSSE } from './api/sse';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { useMailStore } from './stores/mailStore';
|
||||
import { useSessionStore } from './stores/sessionStore';
|
||||
import { useContactStore } from './stores/contactStore';
|
||||
import { useUIStore } from './stores/uiStore';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import MailList from './components/MailList';
|
||||
import ContactPanel from './components/ContactPanel';
|
||||
import MailView from './components/MailView';
|
||||
import ComposePage from './components/ComposePage';
|
||||
import LoginPage from './components/LoginPage';
|
||||
import SetupPage from './components/SetupPage';
|
||||
import AccountPage from './components/AccountPage';
|
||||
import AdminUsersPage from './components/AdminUsersPage';
|
||||
|
||||
export default function App() {
|
||||
const phase = useAuthStore(s => s.phase);
|
||||
const bootstrap = useAuthStore(s => s.bootstrap);
|
||||
const user = useAuthStore(s => s.user);
|
||||
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const composing = useUIStore(s => s.composing);
|
||||
const resetUI = useUIStore(s => s.reset);
|
||||
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const dropMailSession = useMailStore(s => s.dropSession);
|
||||
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const dropSessionIfCurrent = useSessionStore(s => s.dropSessionIfCurrent);
|
||||
const refreshRenameProposal = useSessionStore(s => s.refreshRenameProposal);
|
||||
const refreshBudget = useSessionStore(s => s.refreshBudget);
|
||||
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const removeSessionLocally = useContactStore(s => s.removeSessionLocally);
|
||||
|
||||
// 启动时检测初始化状态 / 登录态
|
||||
useEffect(() => {
|
||||
bootstrap();
|
||||
}, []);
|
||||
|
||||
// 登出后清理客户端状态,避免脏数据残留
|
||||
useEffect(() => {
|
||||
if (phase === 'anonymous') {
|
||||
resetUI();
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
// 登录态就绪后拉取数据 + SSE
|
||||
useEffect(() => {
|
||||
if (phase !== 'authenticated') return;
|
||||
|
||||
fetchInbox('all');
|
||||
fetchSent();
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
|
||||
return connectSSE((type, data) => {
|
||||
switch (type) {
|
||||
case 'new_mail':
|
||||
fetchInbox('all');
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
// 新来信可能带改名建议;Agent 发信也会消耗本任务的往返预算
|
||||
refreshRenameProposal();
|
||||
refreshBudget();
|
||||
break;
|
||||
case 'session_update':
|
||||
// 别人(或另一个标签页)改了预算/别名
|
||||
refreshBudget();
|
||||
fetchInbox('all');
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
break;
|
||||
case 'permission_decision':
|
||||
fetchInbox('all');
|
||||
fetchSessions();
|
||||
fetchContacts();
|
||||
break;
|
||||
case 'session_archived': {
|
||||
const id = typeof data.session_id === 'string' ? data.session_id : '';
|
||||
if (!id) break;
|
||||
removeSessionLocally(id);
|
||||
dropMailSession(id);
|
||||
dropSessionIfCurrent(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [phase]);
|
||||
|
||||
// loading 阶段
|
||||
if (phase === 'checking') {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
|
||||
<p className="text-sm">加载中</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 未登录
|
||||
if (phase === 'anonymous') {
|
||||
return <AnonymousRoute onBootDone={bootstrap} />;
|
||||
}
|
||||
|
||||
// 已登录主界面
|
||||
return (
|
||||
<div className="h-full flex bg-gray-50">
|
||||
<Sidebar />
|
||||
{viewMode === 'contacts' ? (
|
||||
<ContactPanel />
|
||||
) : viewMode === 'inbox' || viewMode === 'sent' ? (
|
||||
<MailList />
|
||||
) : null}
|
||||
{composing ? (
|
||||
<ComposePage />
|
||||
) : viewMode === 'account' ? (
|
||||
<AccountPage />
|
||||
) : viewMode === 'admin' && user?.role === 'admin' ? (
|
||||
<AdminUsersPage />
|
||||
) : viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'contacts' ? (
|
||||
<MailView />
|
||||
) : (
|
||||
<MailView />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 未登录时:先查 needs_setup,决定展示初始化向导还是登录页 */
|
||||
function AnonymousRoute({ onBootDone }: { onBootDone: () => void }) {
|
||||
// bootstrap 已经在 App 里调过了,此处仅判断路由
|
||||
// 若 bootstrap 已经把 phase 推到 anonymous,needs_setup 需独立查询
|
||||
// 为简化,在 LoginPage 上方嵌套 SetupPage 的判断逻辑
|
||||
return <LoginOrSetup onDone={onBootDone} />;
|
||||
}
|
||||
|
||||
import { setupStatus } from './api/client';
|
||||
function LoginOrSetup({ onDone }: { onDone: () => void }) {
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setupStatus()
|
||||
.then(r => setNeedsSetup(r.needs_setup))
|
||||
.catch(() => setNeedsSetup(false));
|
||||
}, []);
|
||||
|
||||
if (needsSetup === null) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
|
||||
<p className="text-sm">加载中</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return <SetupPage onDone={onDone} />;
|
||||
}
|
||||
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
import { useState } from 'react';
|
||||
489
web/src/api/client.ts
Normal file
489
web/src/api/client.ts
Normal file
@ -0,0 +1,489 @@
|
||||
import type { User } from '../types';
|
||||
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget } from '../types';
|
||||
import { API_BASE, authHeaders, withToken } from './config';
|
||||
|
||||
export { API_BASE, setToken, getToken, authHeaders, withToken } from './config';
|
||||
|
||||
const BASE = API_BASE;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
retryAfter?: number;
|
||||
constructor(status: number, message: string, retryAfter?: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
export function setUnauthorizedHandler(fn: () => void) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
// Cookie 模式需要 include;带 Bearer 时多发一个 Cookie 也无害
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() }
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, init);
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (res.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/setup')) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`, payload.retry_after);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------- 首次初始化 ----------
|
||||
|
||||
export async function setupStatus() {
|
||||
return request<{ needs_setup: boolean }>('GET', '/setup/status');
|
||||
}
|
||||
|
||||
export async function setupAdmin(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
}) {
|
||||
return request<{ user: User }>('POST', '/setup/admin', payload);
|
||||
}
|
||||
|
||||
// ---------- 认证 ----------
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
return request<{ user: User }>('POST', '/auth/login', { username, password });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return request<{ status: string }>('POST', '/auth/logout');
|
||||
}
|
||||
|
||||
export async function me() {
|
||||
return request<{ user: User }>('GET', '/auth/me');
|
||||
}
|
||||
|
||||
export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
return request<{ status: string }>('POST', '/auth/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 管理员:用户管理 ----------
|
||||
|
||||
export async function adminListUsers() {
|
||||
return request<{ users: User[] }>('GET', '/admin/users');
|
||||
}
|
||||
|
||||
export async function adminCreateUser(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
role?: string;
|
||||
allowed_agents?: string[];
|
||||
allowed_paths?: string[];
|
||||
}) {
|
||||
return request<{ user: User }>('POST', '/admin/users', payload);
|
||||
}
|
||||
|
||||
export async function adminUpdateUser(
|
||||
id: string,
|
||||
payload: {
|
||||
display_name?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
allowed_agents?: string[];
|
||||
allowed_paths?: string[];
|
||||
}
|
||||
) {
|
||||
return request<{ user: User }>('PUT', `/admin/users/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function adminDisableUser(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export async function adminResetPassword(id: string, newPassword: string) {
|
||||
return request<{ status: string }>('POST', `/admin/users/${id}/reset`, {
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminListScopes() {
|
||||
const r = await request<{ agents: string[]; paths: string[] }>('GET', '/admin/scopes');
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------- 密钥 ----------
|
||||
|
||||
export type KeyType = 'permanent' | 'one_time' | 'timed';
|
||||
|
||||
/** 密钥全文 key_token 仅在创建响应里出现一次,列表只给 token_hint。 */
|
||||
export interface AgentKey {
|
||||
key_id: string;
|
||||
key_token?: string;
|
||||
token_hint: string;
|
||||
agent_name: string | null;
|
||||
key_type: KeyType;
|
||||
label: string;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserKey {
|
||||
key_id: string;
|
||||
key_token?: string;
|
||||
token_hint: string;
|
||||
label: string;
|
||||
key_type: KeyType;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateKeyPayload {
|
||||
key_type: KeyType;
|
||||
label?: string;
|
||||
/** 仅 timed 需要 */
|
||||
expires_hours?: number;
|
||||
/** 仅 Agent 密钥:留空 = 待绑定,首次注册时落定 */
|
||||
agent_name?: string;
|
||||
/** 仅 Agent 密钥:登记客户端已在本地生成的密钥 */
|
||||
key_token?: string;
|
||||
}
|
||||
|
||||
export async function adminListAgentKeys(agentName?: string) {
|
||||
const q = agentName ? `?agent_name=${encodeURIComponent(agentName)}` : '';
|
||||
return request<{ keys: AgentKey[] }>('GET', `/admin/agent-keys${q}`);
|
||||
}
|
||||
|
||||
export async function adminCreateAgentKey(payload: CreateKeyPayload) {
|
||||
return request<{ key: AgentKey }>('POST', '/admin/agent-keys', payload);
|
||||
}
|
||||
|
||||
export async function adminDeleteAgentKey(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/admin/agent-keys/${id}`);
|
||||
}
|
||||
|
||||
export async function adminBindAgentKey(id: string, agentName: string) {
|
||||
return request<{ status: string; agent_name: string }>(
|
||||
'POST',
|
||||
`/admin/agent-keys/${id}/bind`,
|
||||
{ agent_name: agentName }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMyKeys() {
|
||||
return request<{ keys: UserKey[] }>('GET', '/me/keys');
|
||||
}
|
||||
|
||||
export async function createMyKey(payload: CreateKeyPayload) {
|
||||
return request<{ key: UserKey }>('POST', '/me/keys', payload);
|
||||
}
|
||||
|
||||
export async function deleteMyKey(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/me/keys/${id}`);
|
||||
}
|
||||
|
||||
// ---------- Agents ----------
|
||||
|
||||
export async function listAgents(status?: string) {
|
||||
const q = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
return request<{ agents: Agent[] }>('GET', `/agents${q}`);
|
||||
}
|
||||
|
||||
// ---------- 自己的邮箱 ----------
|
||||
|
||||
export interface SendMailOpts {
|
||||
cc?: string;
|
||||
reply_to?: string;
|
||||
/** 仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈 */
|
||||
session_alias?: string;
|
||||
/** 先用 uploadAttachment 上传取得的 id 列表 */
|
||||
attachment_ids?: string[];
|
||||
/**
|
||||
* 本任务的往返预算(0/省略 = 不限)。仅在新建会话时生效;
|
||||
* 续谈已有会话请用 updateSessionBudget(对话页里可随时改)。
|
||||
*/
|
||||
max_rounds?: number;
|
||||
}
|
||||
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
opts: SendMailOpts = {}
|
||||
) {
|
||||
return request<{
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
budget_max?: number;
|
||||
budget_used?: number;
|
||||
budget_remaining?: number;
|
||||
}>('POST', '/me/mail/send', {
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc: opts.cc ?? '',
|
||||
reply_to: opts.reply_to ?? '',
|
||||
session_alias: opts.session_alias ?? '',
|
||||
attachment_ids: opts.attachment_ids ?? [],
|
||||
// null 而非 0:0 是「不限」的合法取值,省略才表示「不设置」
|
||||
max_rounds: opts.max_rounds ?? null
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInbox(status = 'all', limit = 50) {
|
||||
return request<{ mails: Mail[]; total: number }>(
|
||||
'GET',
|
||||
`/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSent() {
|
||||
return request<{ mails: Mail[] }>('GET', '/me/mail/sent');
|
||||
}
|
||||
|
||||
export async function getMail(id: string) {
|
||||
return request<Mail>('GET', `/mail/${id}`);
|
||||
}
|
||||
|
||||
export async function markMailRead(id: string) {
|
||||
return request<{ status: string }>('POST', `/mail/${id}/read`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取线索的一块。
|
||||
*
|
||||
* dir=around 首屏(锚点 + 部分祖先 + 部分子孙),up/down 配 offset 增量加载。
|
||||
* 树可跨会话,服务端按会话逐个鉴权,看不到的节点不返回并计入 hidden。
|
||||
*/
|
||||
export async function getMailThread(
|
||||
id: string,
|
||||
opts: { dir?: 'around' | 'up' | 'down'; offset?: number; limit?: number } = {}
|
||||
) {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.dir) q.set('dir', opts.dir);
|
||||
if (opts.offset !== undefined) q.set('offset', String(opts.offset));
|
||||
if (opts.limit !== undefined) q.set('limit', String(opts.limit));
|
||||
const qs = q.toString();
|
||||
return request<ThreadPage>('GET', `/mail/${id}/thread${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
|
||||
/**
|
||||
* 上传附件,返回 attachment_id。
|
||||
*
|
||||
* 不能走 request():那里固定 Content-Type: application/json,
|
||||
* 而 multipart 必须让浏览器自己带 boundary。
|
||||
*/
|
||||
export async function uploadAttachment(file: File, onProgress?: (pct: number) => void) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
// 需要进度就用 XHR —— fetch 至今没有上传进度事件
|
||||
if (onProgress) {
|
||||
return new Promise<{ attachment: Attachment }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${BASE}/me/attachments`);
|
||||
xhr.withCredentials = true;
|
||||
for (const [k, v] of Object.entries(authHeaders())) xhr.setRequestHeader(k, v);
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
xhr.onload = () => {
|
||||
let payload: { attachment?: Attachment; error?: string } = {};
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* 非 JSON 响应按状态码处理 */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300 && payload.attachment) {
|
||||
resolve({ attachment: payload.attachment });
|
||||
} else {
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
reject(new ApiError(xhr.status, payload.error || `HTTP ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, '网络错误'));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}/me/attachments`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
// 不设 Content-Type:multipart 的 boundary 要交给浏览器生成
|
||||
headers: authHeaders(),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (res.status === 401) onUnauthorized?.();
|
||||
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ attachment: Attachment }>;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件下载链接。由浏览器直接发起(<a download>),因此无法带 Authorization 头:
|
||||
* Cookie 模式靠同源 Cookie,密钥模式回退到 ?access_token=。
|
||||
*/
|
||||
export function attachmentURL(id: string) {
|
||||
return withToken(`${BASE}/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/** 人类可读的字节数 */
|
||||
export function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export interface ForwardPayload {
|
||||
/** 新收件人的三维地址 */
|
||||
to: string;
|
||||
/** 转发说明,置于引用原文之前 */
|
||||
comment?: string;
|
||||
cc?: string;
|
||||
/** 留空则自动加 Fwd: 前缀 */
|
||||
subject?: string;
|
||||
/** 仅当 to 以 .new 结尾时生效 */
|
||||
session_alias?: string;
|
||||
}
|
||||
|
||||
export async function forwardMail(id: string, payload: ForwardPayload) {
|
||||
return request<{
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
forwarded_from: string;
|
||||
}>('POST', `/me/mail/${id}/forward`, {
|
||||
to: payload.to,
|
||||
comment: payload.comment ?? '',
|
||||
cc: payload.cc ?? '',
|
||||
subject: payload.subject ?? '',
|
||||
session_alias: payload.session_alias ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 配额 ----------
|
||||
|
||||
export interface Quota {
|
||||
agent_name: string;
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
/** 不限额时为 -1 */
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export async function adminListQuotas() {
|
||||
return request<{ quotas: Quota[] }>('GET', '/admin/quotas');
|
||||
}
|
||||
|
||||
/** 设上限(0 = 不限)或把已用次数归零 */
|
||||
export async function adminSetQuota(
|
||||
agentName: string,
|
||||
payload: { max_rounds?: number; reset?: boolean }
|
||||
) {
|
||||
return request<{ quota: Quota }>('PUT', `/admin/quotas/${encodeURIComponent(agentName)}`, payload);
|
||||
}
|
||||
|
||||
// ---------- Sessions ----------
|
||||
|
||||
export async function getHumanSessions() {
|
||||
return request<{ sessions: HumanSession[] }>('GET', '/me/sessions');
|
||||
}
|
||||
|
||||
export async function getSessionDetail(id: string) {
|
||||
return request<SessionDetail>('GET', `/sessions/${id}`);
|
||||
}
|
||||
|
||||
export async function updateSessionAlias(id: string, alias: string) {
|
||||
return request<{ status: string; alias: string }>('PUT', `/sessions/${id}/alias`, { alias });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该会话里最新一条尚未处理的改名提议(Agent 在邮件正文里提的)。
|
||||
* 已接受(提议就是当前别名)或已驳回的不再返回。
|
||||
*/
|
||||
export async function getRenameProposal(id: string) {
|
||||
return request<{ proposal: RenameProposal | null }>('GET', `/sessions/${id}/rename-proposal`);
|
||||
}
|
||||
|
||||
/** 本会话(= 本任务)的往返预算。 */
|
||||
export async function getSessionBudget(id: string) {
|
||||
return request<SessionBudget>('GET', `/sessions/${id}/budget`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改本会话的往返预算。
|
||||
*
|
||||
* max_rounds = 0 表示不限;reset 把已用次数归零。两者可同时给
|
||||
* (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。
|
||||
*/
|
||||
export async function updateSessionBudget(
|
||||
id: string,
|
||||
patch: { max_rounds?: number; reset?: boolean }
|
||||
) {
|
||||
return request<SessionBudget>('PUT', `/sessions/${id}/budget`, patch);
|
||||
}
|
||||
|
||||
/** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */
|
||||
export async function dismissRenameProposal(id: string) {
|
||||
return request<{ status: string; dismissed?: string }>(
|
||||
'POST',
|
||||
`/sessions/${id}/rename-proposal/dismiss`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Contacts ----------
|
||||
|
||||
export async function listContacts(archived = false) {
|
||||
return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`);
|
||||
}
|
||||
|
||||
export async function suggestAddress(name?: string, path?: string) {
|
||||
const p = new URLSearchParams();
|
||||
if (name) p.set('name', name);
|
||||
if (path) p.set('path', path);
|
||||
return request<SuggestResult>('GET', `/contacts/suggest?${p.toString()}`);
|
||||
}
|
||||
|
||||
export async function archiveContact(payload: { address?: string; session_id?: string }) {
|
||||
return request<{ status: string; session_id: string; session_alias: string }>(
|
||||
'POST',
|
||||
'/contacts/archive',
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Permission ----------
|
||||
|
||||
export async function decidePermission(mailId: string, decision: string, note?: string) {
|
||||
return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', {
|
||||
mail_id: mailId,
|
||||
decision,
|
||||
note: note ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPendingPermissions() {
|
||||
return request<{ requests: PermissionRequest[] }>('GET', '/permission/pending');
|
||||
}
|
||||
68
web/src/api/config.ts
Normal file
68
web/src/api/config.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* API 接入配置。
|
||||
*
|
||||
* WebUI 与第三方客户端调用的是**同一套 WebAPI**,差别只在两点:
|
||||
* 1. 基地址:内嵌在 Gateway 里时是同源的 /api/v1;独立部署的客户端需要指向具体主机
|
||||
* 2. 凭证:浏览器用登录 Cookie;第三方客户端用用户密钥(Authorization: Bearer)
|
||||
*
|
||||
* 这两点都在此处集中配置,业务代码不感知差异 —— 这样把 src/api/ 整个抽成 SDK 时
|
||||
* 不需要改任何调用点。
|
||||
*/
|
||||
|
||||
/** 运行时注入点:宿主页面可在加载 bundle 前设置这两个全局量 */
|
||||
declare global {
|
||||
interface Window {
|
||||
__AGENTMAIL_API_BASE__?: string;
|
||||
__AGENTMAIL_TOKEN__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基地址优先级:运行时全局 > 构建期环境变量 > 同源默认值。
|
||||
*
|
||||
* 运行时优先是为了让同一份构建产物能部署到不同后端(容器镜像不必按环境重打)。
|
||||
*/
|
||||
function resolveBase(): string {
|
||||
const runtime = typeof window !== 'undefined' ? window.__AGENTMAIL_API_BASE__ : undefined;
|
||||
const build = import.meta.env?.VITE_API_BASE as string | undefined;
|
||||
const base = (runtime || build || '/api/v1').trim();
|
||||
// 统一去掉尾部斜杠,拼接时只在 path 侧带前导斜杠
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export const API_BASE = resolveBase();
|
||||
|
||||
/** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */
|
||||
let bearerToken: string | null =
|
||||
(typeof window !== 'undefined' ? window.__AGENTMAIL_TOKEN__ : undefined) ?? null;
|
||||
|
||||
/**
|
||||
* 设置用户密钥。第三方客户端在启动时调用一次即可,
|
||||
* 之后所有请求(含 SSE 与附件下载)自动带上。
|
||||
*/
|
||||
export function setToken(token: string | null) {
|
||||
bearerToken = token && token.trim() !== '' ? token.trim() : null;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return bearerToken;
|
||||
}
|
||||
|
||||
/** 认证请求头。用 Cookie 时返回空对象。 */
|
||||
export function authHeaders(): Record<string, string> {
|
||||
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
|
||||
* - EventSource(SSE)不支持自定义头
|
||||
* - <a download> / <img src> 由浏览器直接发起
|
||||
*
|
||||
* 服务端仅在 SSE 与附件下载这两处接受 ?access_token=,
|
||||
* 其余接口一律要求请求头 —— URL 里的令牌会进访问日志。
|
||||
*/
|
||||
export function withToken(url: string): string {
|
||||
if (!bearerToken) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}access_token=${encodeURIComponent(bearerToken)}`;
|
||||
}
|
||||
76
web/src/api/sse.ts
Normal file
76
web/src/api/sse.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { API_BASE, withToken } from './config';
|
||||
|
||||
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
|
||||
|
||||
const EVENTS = [
|
||||
'new_mail',
|
||||
'permission_decision',
|
||||
'session_update',
|
||||
'session_archived',
|
||||
'agent_online'
|
||||
] as const;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let handlers: SSEEventHandler[] = [];
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backoff = 1000;
|
||||
|
||||
export function connectSSE(onEvent: SSEEventHandler): () => void {
|
||||
handlers.push(onEvent);
|
||||
if (!es) open();
|
||||
|
||||
return () => {
|
||||
handlers = handlers.filter(h => h !== onEvent);
|
||||
if (handlers.length === 0) close();
|
||||
};
|
||||
}
|
||||
|
||||
function open() {
|
||||
close(false);
|
||||
// EventSource 无法设置请求头:Cookie 模式靠同源 Cookie,
|
||||
// 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。
|
||||
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
backoff = 1000;
|
||||
});
|
||||
|
||||
for (const name of EVENTS) {
|
||||
es.addEventListener(name, (e: MessageEvent) => {
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch {
|
||||
/* 忽略非 JSON 负载 */
|
||||
}
|
||||
handlers.forEach(h => h(name, data));
|
||||
});
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
close(false);
|
||||
if (handlers.length === 0) return;
|
||||
if (retryTimer) return;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
backoff = Math.min(backoff * 2, 15000);
|
||||
open();
|
||||
}, backoff);
|
||||
};
|
||||
}
|
||||
|
||||
function close(clearHandlers = true) {
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
if (clearHandlers) handlers = [];
|
||||
}
|
||||
|
||||
export function disconnectSSE() {
|
||||
close();
|
||||
}
|
||||
218
web/src/components/AccountPage.tsx
Normal file
218
web/src/components/AccountPage.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { LockIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
|
||||
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
|
||||
export default function AccountPage() {
|
||||
const user = useAuthStore(s => s.user);
|
||||
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="px-6 py-3 border-b border-gray-200">
|
||||
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
|
||||
</div>
|
||||
|
||||
<div className="max-w-lg 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
176
web/src/components/AddressInput.tsx
Normal file
176
web/src/components/AddressInput.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
|
||||
/**
|
||||
* 三段式地址输入:name -> @path -> .session
|
||||
* 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。
|
||||
* 值本身始终是完整字符串 name@path.session。
|
||||
*/
|
||||
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[]>([]);
|
||||
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
|
||||
const [active, setActive] = useState(0);
|
||||
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 filtered = (res.suggestions || []).filter(s =>
|
||||
s.toLowerCase().includes(frag.toLowerCase())
|
||||
);
|
||||
setKind(res.kind);
|
||||
setItems(filtered);
|
||||
setActive(0);
|
||||
} catch {
|
||||
if (!cancelled) setItems([]);
|
||||
}
|
||||
};
|
||||
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);
|
||||
}, []);
|
||||
|
||||
/** 选中一个候选后拼回完整地址 */
|
||||
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 mt-1 w-full max-h-56 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
|
||||
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
|
||||
{hint}
|
||||
</div>
|
||||
{items.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
apply(s);
|
||||
}}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`w-full text-left px-2.5 py-1.5 text-sm font-mono ${
|
||||
i === active ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
{kind === 'session' && s === 'new' && (
|
||||
<span className="ml-2 text-[10px] text-gray-400 font-sans">新建会话</span>
|
||||
)}
|
||||
</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
|
||||
};
|
||||
}
|
||||
421
web/src/components/AdminUsersPage.tsx
Normal file
421
web/src/components/AdminUsersPage.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
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 } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
import QuotaPanel from './QuotaPanel';
|
||||
|
||||
type Tab = 'users' | 'keys' | 'quotas';
|
||||
|
||||
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-6 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<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" />
|
||||
发信配额
|
||||
</TabButton>
|
||||
<div className="flex-1" />
|
||||
{notice && <span className="text-xs text-green-600">{notice}</span>}
|
||||
{tab === 'users' && (
|
||||
<button onClick={() => setCreating(v => !v)} className="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 === 'quotas' ? (
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<QuotaPanel />
|
||||
</div>
|
||||
) : tab === 'keys' ? (
|
||||
<div className="flex-1 overflow-y-auto 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-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={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
|
||||
active ? 'bg-gray-900 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-3">
|
||||
<button onClick={onToggle} className="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="text-[11px] text-gray-500">{user.display_name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-purple-100 text-purple-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-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-3">
|
||||
<button onClick={save} disabled={busy} className="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">
|
||||
<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="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-gray-700 text-white hover:bg-gray-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-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="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>
|
||||
);
|
||||
}
|
||||
168
web/src/components/Attachments.tsx
Normal file
168
web/src/components/Attachments.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
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">
|
||||
<button
|
||||
onClick={pick}
|
||||
disabled={disabled || uploading !== null}
|
||||
className="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="inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
{uploading.name} {uploading.pct}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
{items.length > 0 && !uploading && (
|
||||
<span className="text-[11px] text-gray-400">
|
||||
{items.length} 个附件 ·{' '}
|
||||
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-red-600">{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
|
||||
onClick={() => remove(a)}
|
||||
disabled={disabled}
|
||||
title="移除"
|
||||
className="shrink-0 text-gray-400 hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
web/src/components/ComposePage.tsx
Normal file
275
web/src/components/ComposePage.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
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 { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
import { ComposeIcon } 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 [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('');
|
||||
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]);
|
||||
|
||||
// 会话别名只在新建会话(地址以 .new 结尾)时有意义;
|
||||
// 命中已有会话或走默认会话时后端会忽略该字段。
|
||||
const isNewSession = /\.new\s*$/.test(to.trim());
|
||||
const aliasError =
|
||||
isNewSession && sessionAlias.trim() !== '' && /[.\s/@]/.test(sessionAlias.trim())
|
||||
? '别名不可含 . 空白 / 或 @'
|
||||
: isNewSession && sessionAlias.trim() === 'new'
|
||||
? '"new" 是寻址保留字'
|
||||
: null;
|
||||
|
||||
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()) }
|
||||
: {})
|
||||
});
|
||||
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 flex flex-col bg-white">
|
||||
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<ComposeIcon className="w-4 h-4 text-blue-600" />
|
||||
<h2 className="text-sm font-semibold text-gray-900">新建邮件</h2>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setSessionAlias('');
|
||||
setMaxRounds('');
|
||||
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
|
||||
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
|
||||
setAttachments([]);
|
||||
setError(null);
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="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
|
||||
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>
|
||||
)}
|
||||
|
||||
{isNewSession && (
|
||||
<Field
|
||||
label="往返预算"
|
||||
hint="可选;留空或 0 = 不限。之后可在对话页随时调整"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={maxRounds}
|
||||
onChange={e => setMaxRounds(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
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="flex-1 min-h-0 px-6 py-3 flex flex-col">
|
||||
<div className="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="flex-1 min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4 prose prose-sm max-w-none">
|
||||
{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-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-3">
|
||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-t border-gray-200 flex items-center gap-3">
|
||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>}
|
||||
<div className="flex-1" />
|
||||
<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={`text-[11px] px-2 py-0.5 rounded ${
|
||||
active ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
217
web/src/components/ContactPanel.tsx
Normal file
217
web/src/components/ContactPanel.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
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 } from './icons';
|
||||
|
||||
/**
|
||||
* 左侧联系人面板:列出所有 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 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);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, []);
|
||||
|
||||
const open = (c: Contact) => {
|
||||
cancelCompose();
|
||||
clearCurrentMail();
|
||||
selectSession(c.session_id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
|
||||
<h2 className="text-sm font-semibold text-gray-800">联系人</h2>
|
||||
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleArchivedView}
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
||||
showArchived ? 'bg-gray-900 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 => (
|
||||
<ContactRow
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
confirming={pendingArchive === c.address}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
onRequestArchive={() => requestArchive(c.address)}
|
||||
onCancelArchive={cancelArchive}
|
||||
onConfirmArchive={() => archive(c)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">
|
||||
暂无联系人,发一封邮件即可建立
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactRow({
|
||||
contact,
|
||||
active,
|
||||
confirming,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onRequestArchive,
|
||||
onCancelArchive,
|
||||
onConfirmArchive
|
||||
}: {
|
||||
contact: Contact;
|
||||
active: boolean;
|
||||
confirming: boolean;
|
||||
onOpen: () => void;
|
||||
onCompose: () => void;
|
||||
onRequestArchive: () => void;
|
||||
onCancelArchive: () => void;
|
||||
onConfirmArchive: () => void;
|
||||
}) {
|
||||
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
if (confirming) {
|
||||
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={onConfirmArchive}
|
||||
className="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={onCancelArchive}
|
||||
className="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>
|
||||
);
|
||||
}
|
||||
|
||||
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-500 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="flex gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={onCompose}
|
||||
title="写信给该地址"
|
||||
className="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="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
web/src/components/KeyPanel.tsx
Normal file
311
web/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="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="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-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="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-3">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
108
web/src/components/LoginPage.tsx
Normal file
108
web/src/components/LoginPage.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
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('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100">
|
||||
<div className="w-[380px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 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>
|
||||
);
|
||||
}
|
||||
137
web/src/components/MailList.tsx
Normal file
137
web/src/components/MailList.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import type { Mail } from '../types';
|
||||
import { ShieldIcon, PaperclipIcon } from './icons';
|
||||
|
||||
export default function MailList() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'sent') fetchSent();
|
||||
else if (viewMode === 'inbox') fetchInbox('all');
|
||||
}, [viewMode]);
|
||||
|
||||
const isSent = viewMode === 'sent';
|
||||
const list = isSent ? sent : inbox;
|
||||
|
||||
const pick = (m: Mail) => {
|
||||
clearSession();
|
||||
cancelCompose();
|
||||
selectMail(m);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
|
||||
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||
{list.map(m => (
|
||||
<MailItem
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMail?.mail_id === m.mail_id}
|
||||
showTo={isSent}
|
||||
onClick={() => pick(m)}
|
||||
/>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">暂无邮件</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MailItem({
|
||||
mail,
|
||||
active,
|
||||
showTo,
|
||||
onClick
|
||||
}: {
|
||||
mail: Mail;
|
||||
active: boolean;
|
||||
showTo: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
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 peer = showTo
|
||||
? `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`
|
||||
: `${mail.from_name}${mail.from_workspace ? '@' + mail.from_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 font-mono ${
|
||||
isUnread ? '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">
|
||||
{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">
|
||||
{mail.session_alias && (
|
||||
<span className="text-[10px] text-blue-500 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>
|
||||
);
|
||||
}
|
||||
578
web/src/components/MailView.tsx
Normal file
578
web/src/components/MailView.tsx
Normal file
@ -0,0 +1,578 @@
|
||||
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 * 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';
|
||||
|
||||
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);
|
||||
// 转发面板作用于哪封邮件;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];
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-6 py-3 border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<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" />
|
||||
<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-6 py-4 space-y-3">
|
||||
{currentSessionMails.map(m => (
|
||||
<ThreadCard key={m.mail_id} mail={m} />
|
||||
))}
|
||||
</div>
|
||||
<ReplyBar replyTo={last} />
|
||||
</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">选择一封邮件查看,或点击左侧「新建」写邮件</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-6 py-4">
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<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 的全局配额是另一回事 —— 那管的是「这个 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-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-500 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 [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(), 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="border-t border-gray-200 bg-white 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" />
|
||||
<span className="text-[10px] text-gray-400">原文将以引用块附在下方</span>
|
||||
</div>
|
||||
|
||||
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人:pi@root.new" />
|
||||
|
||||
<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">
|
||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !to.trim()}
|
||||
className="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');
|
||||
const from = `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}${
|
||||
mail.session_alias ? '.' + mail.session_alias : ''
|
||||
}`;
|
||||
const to = `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<h2 className="text-sm font-semibold text-gray-900">{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="text-xs text-blue-500 hover:underline">
|
||||
标记已读
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onThread}
|
||||
className="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="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).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>
|
||||
<dd className="font-mono text-gray-600 break-all">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard({ mail }: { mail: Mail }) {
|
||||
const isHuman = mail.from_name === 'human';
|
||||
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'
|
||||
: isHuman
|
||||
? 'border-blue-200 bg-blue-50/60'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-xs">
|
||||
{isHuman ? (
|
||||
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
|
||||
) : (
|
||||
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
|
||||
)}
|
||||
<span className="font-semibold text-gray-800 font-mono">
|
||||
{isHuman ? 'human' : 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">抄送 {mail.cc_list.length}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-gray-400">{time}</span>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
|
||||
</div>
|
||||
<AttachmentList items={mail.attachments ?? []} />
|
||||
{isPermission && <PermissionPanel mail={mail} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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-600 text-white hover:bg-green-700'
|
||||
: '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 }: { replyTo?: Mail }) {
|
||||
const [body, setBody] = useState('');
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
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);
|
||||
|
||||
if (!replyTo) return null;
|
||||
|
||||
// 回给对端:若这封是我(human)发的,则回给收件人,否则回给发件人
|
||||
const peerName = replyTo.from_name === 'human' ? replyTo.to_name : replyTo.from_name;
|
||||
const peerPath = replyTo.from_name === 'human' ? replyTo.to_workspace : replyTo.from_workspace;
|
||||
const target = `${peerName}@${peerPath || ''}${
|
||||
replyTo.session_alias ? '.' + replyTo.session_alias : ''
|
||||
}`;
|
||||
|
||||
const send = async () => {
|
||||
if (!body.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
|
||||
reply_to: replyTo.mail_id,
|
||||
attachment_ids: attachments.map(a => a.id)
|
||||
});
|
||||
setBody('');
|
||||
setAttachments([]);
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
if (replyTo.session_id) selectSession(replyTo.session_id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 bg-white px-6 py-3">
|
||||
<p className="text-[10px] text-gray-400 mb-1 font-mono">回复 {target}</p>
|
||||
<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 justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setBody('')}
|
||||
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={busy || !body.trim()}
|
||||
className="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>;
|
||||
}
|
||||
130
web/src/components/QuotaPanel.tsx
Normal file
130
web/src/components/QuotaPanel.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { BotIcon, CheckIcon } from './icons';
|
||||
|
||||
/**
|
||||
* Agent 发信配额面板(管理员)。
|
||||
*
|
||||
* 配额限制的是 Agent 主动发信的次数,不限制收信 —— 卡住收信只会让邮件凭空消失,
|
||||
* 卡住发信才能阻止 Agent 无限自我循环。上限 0 表示不限。
|
||||
*/
|
||||
export default function QuotaPanel() {
|
||||
const [quotas, setQuotas] = useState<api.Quota[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.adminListQuotas();
|
||||
setQuotas(r.quotas);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const apply = async (name: string, payload: { max_rounds?: number; reset?: boolean }) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminSetQuota(name, payload);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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">{quotas.length}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
限制 Agent 主动发信的次数(不限制收信)。上限填 0 表示不限。
|
||||
剩余次数会随心跳与发信响应回传给 Agent,好让它在额度用尽前主动发最终总结。
|
||||
</p>
|
||||
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
|
||||
{quotas.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">
|
||||
{quotas.map(q => {
|
||||
const draft = drafts[q.agent_name] ?? String(q.max_rounds);
|
||||
const dirty = draft !== String(q.max_rounds);
|
||||
const exhausted = !q.unlimited && q.remaining === 0;
|
||||
return (
|
||||
<div key={q.agent_name} className="px-3 py-2.5 flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
|
||||
{q.agent_name}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{q.unlimited ? (
|
||||
<span className="text-xs text-gray-500">不限额(已用 {q.used_rounds})</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-28 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${exhausted ? 'bg-red-400' : 'bg-blue-400'}`}
|
||||
style={{
|
||||
width: `${Math.min(100, (q.used_rounds / Math.max(1, q.max_rounds)) * 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] ${exhausted ? 'text-red-600' : 'text-gray-500'}`}
|
||||
>
|
||||
{q.used_rounds}/{q.max_rounds}
|
||||
{exhausted && ' · 已用尽'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft}
|
||||
onChange={e => setDrafts(d => ({ ...d, [q.agent_name]: e.target.value }))}
|
||||
className="w-16 text-xs border border-gray-300 rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => apply(q.agent_name, { max_rounds: Math.max(0, Number(draft) || 0) })}
|
||||
disabled={!dirty || busy === q.agent_name}
|
||||
title="保存上限"
|
||||
className="shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => apply(q.agent_name, { reset: true })}
|
||||
disabled={busy === q.agent_name || q.used_rounds === 0}
|
||||
className="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
web/src/components/SetupPage.tsx
Normal file
134
web/src/components/SetupPage.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-100">
|
||||
<div className="w-[420px] max-w-[92vw] bg-white rounded-xl shadow-sm border border-gray-200 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>
|
||||
);
|
||||
}
|
||||
109
web/src/components/Sidebar.tsx
Normal file
109
web/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
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
|
||||
} from './icons';
|
||||
|
||||
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: 'sent', Icon: SentIcon },
|
||||
{ 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);
|
||||
const unread = inbox.filter(m => m.status === 'unread').length;
|
||||
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] shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900">
|
||||
{navItems
|
||||
.filter(n => !n.adminOnly || isAdmin)
|
||||
.map(({ short, title, mode, Icon }) => {
|
||||
const active = viewMode === mode && !composing;
|
||||
const badge = mode === 'inbox' ? unread : 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-slate-700 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-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-500 text-white' : 'bg-slate-600 text-slate-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-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
<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-slate-700">
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{(user?.display_name || user?.username || '?').slice(0, 2)}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
title="退出登录"
|
||||
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
|
||||
>
|
||||
<LogoutIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
web/src/components/ThreadView.tsx
Normal file
291
web/src/components/ThreadView.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import type { ThreadNode } from '../types';
|
||||
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 对话树视图(按方向分块加载)。
|
||||
*
|
||||
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
|
||||
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
|
||||
*
|
||||
* 加载策略:首屏取锚点附近一块,往上滑补祖先、往下滑补子孙,直到两端都取完。
|
||||
* 一条线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||||
*
|
||||
* 不用 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 [moreUp, setMoreUp] = useState(false);
|
||||
const [moreDown, setMoreDown] = useState(false);
|
||||
const [nextUp, setNextUp] = useState(0);
|
||||
const [nextDown, setNextDown] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
const [initial, setInitial] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinel = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinel = useRef<HTMLDivElement>(null);
|
||||
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
|
||||
const gen = useRef(0);
|
||||
// loading 的同步副本。setState 是异步的,两个 sentinel 同时进入视口时
|
||||
// 读 state 会双双看到 false 而并发发两个请求。
|
||||
const busy = useRef(false);
|
||||
|
||||
const merge = useCallback((incoming: ThreadNode[]) => {
|
||||
setNodes(prev => {
|
||||
const seen = new Set(prev.map(n => n.mail_id));
|
||||
const added = incoming.filter(n => !seen.has(n.mail_id));
|
||||
// 按相对深度排;同深度保持服务端给的时间序
|
||||
return [...prev, ...added].sort((a, b) => a.depth - b.depth);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 首屏
|
||||
useEffect(() => {
|
||||
const myGen = ++gen.current;
|
||||
setNodes([]);
|
||||
setHidden(0);
|
||||
setErr('');
|
||||
setInitial(true);
|
||||
busy.current = true;
|
||||
api
|
||||
.getMailThread(mailID, { dir: 'around', limit: 40 })
|
||||
.then(p => {
|
||||
if (gen.current !== myGen) return;
|
||||
setNodes(p.nodes.slice().sort((a, b) => a.depth - b.depth));
|
||||
setHidden(p.hidden);
|
||||
setMoreUp(p.has_more_up);
|
||||
setMoreDown(p.has_more_down);
|
||||
setNextUp(p.next_up);
|
||||
setNextDown(p.next_down);
|
||||
})
|
||||
.catch(e => {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (gen.current === myGen) {
|
||||
setInitial(false);
|
||||
busy.current = false;
|
||||
}
|
||||
});
|
||||
}, [mailID]);
|
||||
|
||||
const loadMore = useCallback(
|
||||
async (dir: 'up' | 'down') => {
|
||||
if (busy.current) return;
|
||||
if (dir === 'up' && !moreUp) return;
|
||||
if (dir === 'down' && !moreDown) return;
|
||||
|
||||
const myGen = gen.current;
|
||||
busy.current = true;
|
||||
setLoading(true);
|
||||
|
||||
// 往上加载会在列表顶部插入内容,浏览器保持 scrollTop 不变 → 视觉上内容整体跳走。
|
||||
// 记住加载前的「滚动高度」,加载后按增量补偿,让用户视线停在原处。
|
||||
const el = scrollRef.current;
|
||||
const beforeHeight = el?.scrollHeight ?? 0;
|
||||
const beforeTop = el?.scrollTop ?? 0;
|
||||
|
||||
try {
|
||||
const p = await api.getMailThread(mailID, {
|
||||
dir,
|
||||
offset: dir === 'up' ? nextUp : nextDown,
|
||||
limit: 40
|
||||
});
|
||||
if (gen.current !== myGen) return;
|
||||
merge(p.nodes);
|
||||
setHidden(h => h + p.hidden);
|
||||
if (dir === 'up') {
|
||||
setMoreUp(p.has_more_up);
|
||||
setNextUp(p.next_up);
|
||||
} else {
|
||||
setMoreDown(p.has_more_down);
|
||||
setNextDown(p.next_down);
|
||||
}
|
||||
if (dir === 'up' && el) {
|
||||
// 等这批节点真正渲染出来再补偿,否则读到的还是旧高度
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = beforeTop + (el.scrollHeight - beforeHeight);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (gen.current === myGen) setLoading(false);
|
||||
busy.current = false;
|
||||
}
|
||||
},
|
||||
[mailID, merge, moreUp, moreDown, nextUp, nextDown]
|
||||
);
|
||||
|
||||
// 两端各一个哨兵,进入视口就加载对应方向。
|
||||
// rootMargin 提前 200px 触发,让加载在用户滑到边界前完成。
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const obs = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
if (e.target === topSentinel.current) loadMore('up');
|
||||
if (e.target === bottomSentinel.current) loadMore('down');
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '200px' }
|
||||
);
|
||||
if (topSentinel.current) obs.observe(topSentinel.current);
|
||||
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
|
||||
return () => obs.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
// 相对深度可能是负数(祖先);缩进按「最浅的那个」归零,
|
||||
// 否则祖先未加载时首屏内容会整体缩进一大截。
|
||||
const baseDepth = nodes.length > 0 ? Math.min(...nodes.map(n => n.depth)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900">对话树</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
已加载 {nodes.length} 封
|
||||
{(moreUp || moreDown) && ',滑动加载更多'}
|
||||
{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="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-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 ref={topSentinel} className="h-px" />
|
||||
{moreUp && (
|
||||
<button
|
||||
onClick={() => loadMore('up')}
|
||||
className="w-full mb-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 className="space-y-1.5">
|
||||
{nodes.map(n => (
|
||||
<Node key={n.mail_id} node={n} baseDepth={baseDepth} anchorID={mailID} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{moreDown && (
|
||||
<button
|
||||
onClick={() => loadMore('down')}
|
||||
className="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,
|
||||
baseDepth,
|
||||
anchorID
|
||||
}: {
|
||||
node: ThreadNode;
|
||||
baseDepth: number;
|
||||
anchorID: string;
|
||||
}) {
|
||||
const openMailByID = useMailStore(s => s.openMailByID);
|
||||
const isPermission = node.mail_type === 'permission_request';
|
||||
const isAnchor = node.mail_id === anchorID;
|
||||
// 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条
|
||||
const indent = Math.min(Math.max(node.depth - baseDepth, 0), 8) * 20;
|
||||
const time = new Date(node.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div 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" />
|
||||
)}
|
||||
<span className="text-xs font-mono text-gray-700 truncate">
|
||||
{node.from_name}
|
||||
{node.from_workspace && `@${node.from_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" />
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
289
web/src/components/icons.tsx
Normal file
289
web/src/components/icons.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
// 纯 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>
|
||||
);
|
||||
}
|
||||
13
web/src/index.css
Normal file
13
web/src/index.css
Normal file
@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
70
web/src/stores/authStore.ts
Normal file
70
web/src/stores/authStore.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '../types';
|
||||
import * as api from '../api/client';
|
||||
import { ApiError } from '../api/client';
|
||||
|
||||
type Phase = 'checking' | 'anonymous' | 'authenticated';
|
||||
|
||||
interface AuthState {
|
||||
phase: Phase;
|
||||
user: User | null;
|
||||
error: string | null;
|
||||
retryAfter: number | null;
|
||||
submitting: boolean;
|
||||
|
||||
bootstrap: () => Promise<void>;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
/** 401 时由 api client 回调 */
|
||||
markAnonymous: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>(set => ({
|
||||
phase: 'checking',
|
||||
user: null,
|
||||
error: null,
|
||||
retryAfter: null,
|
||||
submitting: false,
|
||||
|
||||
bootstrap: async () => {
|
||||
try {
|
||||
const { user } = await api.me();
|
||||
set({ phase: 'authenticated', user, error: null });
|
||||
} catch {
|
||||
set({ phase: 'anonymous', user: null });
|
||||
}
|
||||
},
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ submitting: true, error: null, retryAfter: null });
|
||||
try {
|
||||
const { user } = await api.login(username.trim(), password);
|
||||
set({ phase: 'authenticated', user, submitting: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const retry = err instanceof ApiError ? err.retryAfter ?? null : null;
|
||||
set({ error: msg, retryAfter: retry, submitting: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
/* 即使请求失败也在前端登出 */
|
||||
}
|
||||
set({ phase: 'anonymous', user: null, error: null });
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null, retryAfter: null }),
|
||||
|
||||
markAnonymous: () => set({ phase: 'anonymous', user: null })
|
||||
}));
|
||||
|
||||
// 注册全局 401 处理:任何接口返回 401 即回到登录页
|
||||
api.setUnauthorizedHandler(() => {
|
||||
useAuthStore.getState().markAnonymous();
|
||||
});
|
||||
81
web/src/stores/contactStore.ts
Normal file
81
web/src/stores/contactStore.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Contact } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
interface ContactState {
|
||||
contacts: Contact[];
|
||||
archivedContacts: Contact[];
|
||||
showArchived: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** 正在等待归档确认的地址 */
|
||||
pendingArchive: string | null;
|
||||
|
||||
fetchContacts: () => Promise<void>;
|
||||
fetchArchived: () => Promise<void>;
|
||||
toggleArchivedView: () => void;
|
||||
requestArchive: (address: string) => void;
|
||||
cancelArchive: () => void;
|
||||
archive: (contact: Contact) => Promise<void>;
|
||||
/** SSE session_archived 到达时本地即时移除 */
|
||||
removeSessionLocally: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useContactStore = create<ContactState>((set, get) => ({
|
||||
contacts: [],
|
||||
archivedContacts: [],
|
||||
showArchived: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
pendingArchive: null,
|
||||
|
||||
fetchContacts: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { contacts } = await api.listContacts(false);
|
||||
set({ contacts: contacts || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchArchived: async () => {
|
||||
try {
|
||||
const { contacts } = await api.listContacts(true);
|
||||
set({ archivedContacts: contacts || [] });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
toggleArchivedView: () => {
|
||||
const next = !get().showArchived;
|
||||
set({ showArchived: next });
|
||||
if (next) get().fetchArchived();
|
||||
},
|
||||
|
||||
requestArchive: address => set({ pendingArchive: address }),
|
||||
cancelArchive: () => set({ pendingArchive: null }),
|
||||
|
||||
archive: async contact => {
|
||||
try {
|
||||
await api.archiveContact({ session_id: contact.session_id });
|
||||
// 本地即时移除,不等 SSE
|
||||
set(state => ({
|
||||
contacts: state.contacts.filter(c => c.session_id !== contact.session_id),
|
||||
pendingArchive: null
|
||||
}));
|
||||
if (get().showArchived) get().fetchArchived();
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
pendingArchive: null
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeSessionLocally: sessionId =>
|
||||
set(state => ({
|
||||
contacts: state.contacts.filter(c => c.session_id !== sessionId)
|
||||
}))
|
||||
}));
|
||||
91
web/src/stores/mailStore.ts
Normal file
91
web/src/stores/mailStore.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Mail } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
interface MailState {
|
||||
inbox: Mail[];
|
||||
sent: Mail[];
|
||||
currentMail: Mail | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
fetchInbox: (status?: string) => Promise<void>;
|
||||
fetchSent: () => Promise<void>;
|
||||
selectMail: (mail: Mail) => void;
|
||||
/**
|
||||
* 按 id 打开邮件。
|
||||
*
|
||||
* 列表与对话树里的邮件只带正文预览(整棵树带全文可能几百 KB),
|
||||
* 所以点开时得单取一次拿全文与附件清单。
|
||||
*/
|
||||
openMailByID: (id: string) => Promise<void>;
|
||||
clearCurrentMail: () => void;
|
||||
markRead: (id: string) => Promise<void>;
|
||||
/** 某会话归档后,把它的邮件从列表与选中态里剔除 */
|
||||
dropSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useMailStore = create<MailState>((set, get) => ({
|
||||
inbox: [],
|
||||
sent: [],
|
||||
currentMail: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchInbox: async (status = 'all') => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { mails } = await api.getInbox(status);
|
||||
set({ inbox: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSent: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { mails } = await api.getSent();
|
||||
set({ sent: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectMail: mail => set({ currentMail: mail }),
|
||||
|
||||
openMailByID: async id => {
|
||||
try {
|
||||
const mail = await api.getMail(id);
|
||||
set({ currentMail: mail });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
clearCurrentMail: () => set({ currentMail: null }),
|
||||
|
||||
markRead: async id => {
|
||||
try {
|
||||
await api.markMailRead(id);
|
||||
set(state => ({
|
||||
inbox: state.inbox.map(m => (m.mail_id === id ? { ...m, status: 'read' as const } : m)),
|
||||
currentMail:
|
||||
state.currentMail?.mail_id === id
|
||||
? { ...state.currentMail, status: 'read' as const }
|
||||
: state.currentMail
|
||||
}));
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
dropSession: sessionId => {
|
||||
const { currentMail } = get();
|
||||
set(state => ({
|
||||
inbox: state.inbox.filter(m => m.session_id !== sessionId),
|
||||
sent: state.sent.filter(m => m.session_id !== sessionId),
|
||||
currentMail: currentMail?.session_id === sessionId ? null : currentMail
|
||||
}));
|
||||
}
|
||||
}));
|
||||
150
web/src/stores/sessionStore.ts
Normal file
150
web/src/stores/sessionStore.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { create } from 'zustand';
|
||||
import type { HumanSession, Mail, RenameProposal, Session, SessionBudget } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
interface SessionState {
|
||||
sessions: HumanSession[];
|
||||
currentSession: Session | null;
|
||||
currentSessionMails: Mail[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Agent 在正文里提的改名建议;null = 无待处理建议 */
|
||||
renameProposal: RenameProposal | null;
|
||||
|
||||
fetchSessions: () => Promise<void>;
|
||||
selectSession: (sessionId: string) => Promise<void>;
|
||||
clearSession: () => void;
|
||||
/** 重新拉取当前会话的改名建议(收到新邮件时调) */
|
||||
refreshRenameProposal: () => Promise<void>;
|
||||
/** 接受建议:改名成功后清掉提示条并刷新会话 */
|
||||
acceptRename: () => Promise<void>;
|
||||
/** 驳回建议:服务端记下来,不再反复弹同一个 */
|
||||
dismissRename: () => Promise<void>;
|
||||
|
||||
/** 本任务的往返预算;null = 尚未取到 */
|
||||
budget: SessionBudget | null;
|
||||
/** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */
|
||||
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
|
||||
/** 重新拉取预算(Agent 发信后剩余会变) */
|
||||
refreshBudget: () => Promise<void>;
|
||||
/** 归档后若正查看该会话则退出 */
|
||||
dropSessionIfCurrent: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
sessions: [],
|
||||
currentSession: null,
|
||||
currentSessionMails: [],
|
||||
renameProposal: null,
|
||||
budget: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchSessions: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { sessions } = await api.getHumanSessions();
|
||||
set({ sessions: sessions || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectSession: async sessionId => {
|
||||
set({ loading: true, error: null, renameProposal: null, budget: null });
|
||||
try {
|
||||
const detail = await api.getSessionDetail(sessionId);
|
||||
set({
|
||||
currentSession: detail.session,
|
||||
currentSessionMails: detail.mails || [],
|
||||
loading: false
|
||||
});
|
||||
// 改名建议与预算单独取:拿不到不该让整个会话打不开
|
||||
get().refreshRenameProposal();
|
||||
get().refreshBudget();
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshRenameProposal: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const { proposal } = await api.getRenameProposal(id);
|
||||
// 期间用户可能已切走,别把上一会话的建议贴到新会话上
|
||||
if (get().currentSession?.session_id === id) {
|
||||
set({ renameProposal: proposal });
|
||||
}
|
||||
} catch {
|
||||
// 建议是锦上添花,失败静默
|
||||
}
|
||||
},
|
||||
|
||||
acceptRename: async () => {
|
||||
const s = get();
|
||||
const id = s.currentSession?.session_id;
|
||||
const alias = s.renameProposal?.alias;
|
||||
if (!id || !alias) return;
|
||||
try {
|
||||
await api.updateSessionAlias(id, alias);
|
||||
set({ renameProposal: null });
|
||||
// 别名变了,会话详情与列表里的地址都要跟着更新
|
||||
await get().selectSession(id);
|
||||
await get().fetchSessions();
|
||||
} catch (err) {
|
||||
// 别名被别人占用(409)等情况要让用户看到,不能默默失败
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
dismissRename: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
// 先隐藏提示条再发请求:驳回是纯本地意图,等一个往返才消失显得迟钝
|
||||
set({ renameProposal: null });
|
||||
try {
|
||||
await api.dismissRenameProposal(id);
|
||||
} catch {
|
||||
// 记不下来最坏的后果是下次打开又弹一次,不值得打扰用户
|
||||
}
|
||||
},
|
||||
|
||||
refreshBudget: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const b = await api.getSessionBudget(id);
|
||||
// 期间用户可能已切走,别把上一会话的预算贴到新会话上
|
||||
if (get().currentSession?.session_id === id) set({ budget: b });
|
||||
} catch {
|
||||
// 预算读不到不影响看邮件,静默
|
||||
}
|
||||
},
|
||||
|
||||
setBudget: async patch => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const b = await api.updateSessionBudget(id, patch);
|
||||
set({ budget: b });
|
||||
// 列表里也显示预算,改完要跟着刷新
|
||||
await get().fetchSessions();
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
clearSession: () =>
|
||||
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null }),
|
||||
|
||||
dropSessionIfCurrent: sessionId => {
|
||||
if (get().currentSession?.session_id === sessionId) {
|
||||
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null });
|
||||
}
|
||||
set(state => ({
|
||||
sessions: state.sessions.filter(s => s.session_id !== sessionId)
|
||||
}));
|
||||
}
|
||||
}));
|
||||
29
web/src/stores/uiStore.ts
Normal file
29
web/src/stores/uiStore.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ViewMode = 'inbox' | 'sent' | 'contacts' | 'admin' | 'account';
|
||||
|
||||
interface UIState {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
|
||||
/** 右侧主区域是否处于「新建邮件」整页编写态 */
|
||||
composing: boolean;
|
||||
composePrefill: { to?: string; cc?: string } | null;
|
||||
startCompose: (prefill?: { to?: string; cc?: string }) => void;
|
||||
cancelCompose: () => void;
|
||||
|
||||
/** 登出后重置回默认视图 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>(set => ({
|
||||
viewMode: 'inbox',
|
||||
setViewMode: mode => set({ viewMode: mode, composing: false, composePrefill: null }),
|
||||
|
||||
composing: false,
|
||||
composePrefill: null,
|
||||
startCompose: prefill => set({ composing: true, composePrefill: prefill ?? null }),
|
||||
cancelCompose: () => set({ composing: false, composePrefill: null }),
|
||||
|
||||
reset: () => set({ viewMode: 'inbox', composing: false, composePrefill: null })
|
||||
}));
|
||||
212
web/src/types/index.ts
Normal file
212
web/src/types/index.ts
Normal file
@ -0,0 +1,212 @@
|
||||
export interface User {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: 'admin' | 'user';
|
||||
status: 'active' | 'disabled';
|
||||
allowed_agents: string[];
|
||||
allowed_paths: string[];
|
||||
last_login?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
agent_id?: string;
|
||||
agent_name: string;
|
||||
workspaces: Workspace[];
|
||||
platform: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
name: string;
|
||||
path: string;
|
||||
session: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count?: number;
|
||||
/**
|
||||
* 别名是谁定的:
|
||||
* platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
*/
|
||||
alias_source?: 'platform' | 'manual';
|
||||
/** 用户驳回过的改名提议 */
|
||||
rename_dismissed?: string;
|
||||
/**
|
||||
* 本任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
*
|
||||
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
|
||||
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
|
||||
*/
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
}
|
||||
|
||||
/** 会话往返预算快照 */
|
||||
export interface SessionBudget {
|
||||
session_id: string;
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
/** 不限时为 -1 */
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
/** 附件元数据。内容存盘,按 sha256 内容寻址;同内容重复上传不占额外空间。 */
|
||||
export interface Attachment {
|
||||
attachment_id: string;
|
||||
/** 为 null 表示已上传但尚未随邮件发出 */
|
||||
mail_id: string | null;
|
||||
uploader: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Mail {
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
parent_mail_id: string | null;
|
||||
from_name: string;
|
||||
from_workspace: string;
|
||||
to_name: string;
|
||||
to_workspace: string;
|
||||
cc_list: Address[];
|
||||
subject: string;
|
||||
body: string;
|
||||
mail_type: 'normal' | 'permission_request';
|
||||
permission_options: string[] | null;
|
||||
permission_result: string | null;
|
||||
status: 'unread' | 'read' | 'archived';
|
||||
created_at: string;
|
||||
hop_limit?: number;
|
||||
session_alias?: string;
|
||||
body_preview?: string;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话树节点。
|
||||
*
|
||||
* 树由 mails.parent_mail_id 编码:回复指向来信,转发指向被转发的原件。
|
||||
* 因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。
|
||||
*
|
||||
* depth 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
||||
* 分块加载时根可能还没取到,所以不用「距根深度」。
|
||||
*/
|
||||
export interface ThreadNode extends Omit<Mail, 'body'> {
|
||||
depth: number;
|
||||
attachment_count: number;
|
||||
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
|
||||
detached?: boolean;
|
||||
/** 父邮件确实存在但无权查看(区别于「尚未加载」,后者会随上滑补齐) */
|
||||
parent_hidden?: boolean;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface ThreadPage {
|
||||
anchor_mail_id: string;
|
||||
dir: 'around' | 'up' | 'down';
|
||||
nodes: ThreadNode[];
|
||||
total: number;
|
||||
/** 因权限被过滤掉的节点数 */
|
||||
hidden: number;
|
||||
has_more_up: boolean;
|
||||
has_more_down: boolean;
|
||||
/** 下一页 offset,原样回传即可 */
|
||||
next_up: number;
|
||||
next_down: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 在邮件正文里提议的新会话别名。
|
||||
*
|
||||
* 为什么是提议而不是 Agent 直接改:别名是**人**的寻址入口
|
||||
* (name@path.别名)。Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
|
||||
* 提议 + 人点头,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
*/
|
||||
export interface RenameProposal {
|
||||
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
|
||||
alias: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
path: string;
|
||||
session_alias: string;
|
||||
address: string;
|
||||
status: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
last_activity: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
request_id: string;
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
context: string;
|
||||
result: string | null;
|
||||
decided_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
session: Session;
|
||||
mails: Mail[];
|
||||
}
|
||||
|
||||
export interface HumanSession {
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
/** 本任务的往返预算(0 = 不限) */
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
}
|
||||
|
||||
export type SuggestKind = 'name' | 'path' | 'session';
|
||||
|
||||
export interface SuggestResult {
|
||||
kind: SuggestKind;
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
/** 系统初始化状态 */
|
||||
export interface SetupStatus {
|
||||
needs_setup: boolean;
|
||||
}
|
||||
|
||||
/** 管理员可授权范围候选 */
|
||||
export interface AdminScopes {
|
||||
agents: string[];
|
||||
paths: string[];
|
||||
}
|
||||
11
web/src/vite-env.d.ts
vendored
Normal file
11
web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** 构建期可注入的环境变量 */
|
||||
interface ImportMetaEnv {
|
||||
/** API 基地址;不设则用同源 /api/v1 */
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
8
web/tailwind.config.js
Normal file
8
web/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
43
web/test/markdown-xss.test.mjs
Normal file
43
web/test/markdown-xss.test.mjs
Normal file
@ -0,0 +1,43 @@
|
||||
// 回归测试:确认邮件正文的 Markdown 渲染不会执行注入的脚本。
|
||||
// react-markdown 默认不解析 raw HTML(无 rehype-raw),且用 defaultUrlTransform
|
||||
// 清空非 http(s)/mailto 协议的 URL —— 本测试守住这两个前提,防止日后有人
|
||||
// 为了「支持 HTML 邮件」顺手加上 rehype-raw 而不自觉地开了 XSS 口子。
|
||||
//
|
||||
// 运行:node test/markdown-xss.test.mjs
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import React from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// 只有【真实标签】里的危险内容才算漏。
|
||||
// 注意不能直接搜 onerror=:raw HTML 被转义成 <img … onerror=" 后,
|
||||
// 文本里仍含该字样但已无执行能力,按标签边界匹配才不会误报。
|
||||
const dangerous = /<(script|iframe|object|embed)\b|<[a-z][^>]*\son[a-z]+\s*=|<[a-z][^>]*(href|src)\s*=\s*"javascript:/i;
|
||||
|
||||
const payloads = [
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror="alert(1)">',
|
||||
'[click](javascript:alert(1))',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<iframe src="https://evil.com"></iframe>',
|
||||
')',
|
||||
'<div onmouseover="alert(1)">hover</div>',
|
||||
'[ok](https://example.com)',
|
||||
'**bold** `code`',
|
||||
];
|
||||
|
||||
let leaks = 0;
|
||||
for (const p of payloads) {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(Markdown, { remarkPlugins: [remarkGfm] }, p)
|
||||
);
|
||||
const bad = dangerous.test(html);
|
||||
if (bad) leaks++;
|
||||
console.log((bad ? 'LEAK ' : 'safe '), JSON.stringify(p), '->', html.slice(0, 80));
|
||||
}
|
||||
if (leaks > 0) {
|
||||
console.error(`\n失败:${leaks} 处 XSS 泄漏`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n通过:raw HTML 被转义,javascript: URL 被清空');
|
||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
web/vite.config.ts
Normal file
16
web/vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Gateway 默认监听 8180(与 config.Load() 的 PORT 默认值一致)
|
||||
'/api': {
|
||||
target: 'http://localhost:8180',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user