feat: add React UI and AI page sharing tools
Move HTTP page rendering to the React app, fix multi-tool-call message handling, add API docs lookup, web page lookup, and Markdown preview page sharing.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -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>iAs</title>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen text-gray-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3371
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
|
||||
import { UpdatesPage } from "./pages/UpdatesPage";
|
||||
import { MailPage } from "./pages/MailPage";
|
||||
import { MailDetailPage } from "./pages/MailDetailPage";
|
||||
import { ToolsPage } from "./pages/ToolsPage";
|
||||
import { ConfigsPage } from "./pages/ConfigsPage";
|
||||
import { ContextSimulatorPage } from "./pages/ContextSimulatorPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import {
|
||||
GenericWebPageDetailPage,
|
||||
MarkdownWebPageDetailPage,
|
||||
MailWebPageDetailPage,
|
||||
} from "./pages/WebPageDetailPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur border-b border-gray-200">
|
||||
<div className="max-w-5xl mx-auto px-5 h-[52px] flex items-center justify-between">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-xl font-bold text-gray-900 no-underline tracking-tight"
|
||||
>
|
||||
iAs
|
||||
</Link>
|
||||
<nav className="flex gap-1">
|
||||
<Link
|
||||
to="/updates"
|
||||
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
|
||||
>
|
||||
更新日志
|
||||
</Link>
|
||||
<Link
|
||||
to="/mail"
|
||||
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
|
||||
>
|
||||
邮件
|
||||
</Link>
|
||||
<Link
|
||||
to="/tools"
|
||||
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
|
||||
>
|
||||
工具
|
||||
</Link>
|
||||
<Link
|
||||
to="/configs"
|
||||
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
|
||||
>
|
||||
配置
|
||||
</Link>
|
||||
<Link
|
||||
to="/context"
|
||||
className="px-3.5 py-1.5 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
|
||||
>
|
||||
上下文
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 max-w-5xl mx-auto px-5 py-6 pb-16 w-full">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/updates/*" element={<UpdatesPage />} />
|
||||
<Route path="/mail/messages/:id" element={<MailDetailPage />} />
|
||||
<Route path="/mail/:slug" element={<MailWebPageDetailPage />} />
|
||||
<Route path="/md/:slug" element={<MarkdownWebPageDetailPage />} />
|
||||
<Route path="/mail" element={<MailPage />} />
|
||||
<Route path="/web/:pageType/:slug" element={<GenericWebPageDetailPage />} />
|
||||
<Route path="/tools" element={<ToolsPage />} />
|
||||
<Route path="/configs" element={<ConfigsPage />} />
|
||||
<Route path="/context" element={<ContextSimulatorPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC",
|
||||
sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
const BASE_URL = "";
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function apiGet<T = ApiResponse>(
|
||||
path: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
): Promise<T> {
|
||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error((body as ApiResponse).message || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiPost<T = ApiResponse>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({}));
|
||||
throw new Error((errBody as ApiResponse).message || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
interface WechatConfig {
|
||||
id: number;
|
||||
user_name?: string;
|
||||
account_id?: string;
|
||||
base_url?: string;
|
||||
user_id?: string;
|
||||
user_status?: number;
|
||||
token_configured: boolean;
|
||||
updates_buf_present: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface MailConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
source_key: string;
|
||||
imap_host: string;
|
||||
imap_port: number;
|
||||
imap_username: string;
|
||||
mailbox: string;
|
||||
notify_account_id: string;
|
||||
notify_user_id: string;
|
||||
poll_seconds: number;
|
||||
fetch_limit: number;
|
||||
accept_invalid_certs: boolean;
|
||||
enabled: boolean;
|
||||
password_configured: boolean;
|
||||
last_checked_at?: string;
|
||||
last_error?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ConfigsResponse {
|
||||
success: boolean;
|
||||
wechat_total: number;
|
||||
mail_total: number;
|
||||
wechat_accounts: WechatConfig[];
|
||||
mail_accounts: MailConfig[];
|
||||
}
|
||||
|
||||
export function ConfigsPage() {
|
||||
const [data, setData] = useState<ConfigsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<ConfigsResponse>("/v1/user-configs")
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-16 text-red-500">
|
||||
<p>加载失败:{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const total = data.wechat_total + data.mail_total;
|
||||
|
||||
const fmtTime = (t?: string) => {
|
||||
if (!t) return "-";
|
||||
return new Date(t).toLocaleString("zh-CN", { timeZone: "UTC" }) + " UTC";
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">用户配置列表</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500">
|
||||
共 {total} 项配置,包含微信渠道账号和邮件账户配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">微信渠道账号</h2>
|
||||
<span className="text-sm text-gray-400">{data.wechat_accounts.length} 个</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.wechat_accounts.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">
|
||||
暂无微信账号配置
|
||||
</div>
|
||||
) : (
|
||||
data.wechat_accounts.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 md:p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900 break-words">
|
||||
{item.user_name || "未命名微信账号"}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 break-all">
|
||||
{item.account_id || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-green-50 text-green-700">
|
||||
微信
|
||||
</span>
|
||||
</div>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-[120px,1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-gray-400">账号 ID</dt>
|
||||
<dd className="text-gray-700 break-all">{item.account_id || "-"}</dd>
|
||||
<dt className="text-gray-400">用户 ID</dt>
|
||||
<dd className="text-gray-700 break-all">{item.user_id || "-"}</dd>
|
||||
<dt className="text-gray-400">服务地址</dt>
|
||||
<dd className="text-gray-700 break-all">{item.base_url || "-"}</dd>
|
||||
<dt className="text-gray-400">状态值</dt>
|
||||
<dd className="text-gray-700">{item.user_status ?? "-"}</dd>
|
||||
<dt className="text-gray-400">Token</dt>
|
||||
<dd className="text-gray-700">{item.token_configured ? "已配置" : "未配置"}</dd>
|
||||
<dt className="text-gray-400">更新游标</dt>
|
||||
<dd className="text-gray-700">{item.updates_buf_present ? "已配置" : "未配置"}</dd>
|
||||
<dt className="text-gray-400">创建时间</dt>
|
||||
<dd className="text-gray-700">{fmtTime(item.created_at)}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">邮件账户</h2>
|
||||
<span className="text-sm text-gray-400">{data.mail_accounts.length} 个</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.mail_accounts.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">
|
||||
暂无邮件账户配置
|
||||
</div>
|
||||
) : (
|
||||
data.mail_accounts.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 md:p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900 break-words">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 break-all">
|
||||
{item.imap_username} @ {item.imap_host}:{item.imap_port}
|
||||
</p>
|
||||
</div>
|
||||
{item.enabled ? (
|
||||
<span className="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-green-50 text-green-700">
|
||||
已启用
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-gray-100 text-gray-500">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-[120px,1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-gray-400">配置 ID</dt>
|
||||
<dd className="text-gray-700">#{item.id}</dd>
|
||||
<dt className="text-gray-400">来源键</dt>
|
||||
<dd className="text-gray-700 break-all">{item.source_key}</dd>
|
||||
<dt className="text-gray-400">邮箱目录</dt>
|
||||
<dd className="text-gray-700">{item.mailbox}</dd>
|
||||
<dt className="text-gray-400">通知账号</dt>
|
||||
<dd className="text-gray-700 break-all">{item.notify_account_id}</dd>
|
||||
<dt className="text-gray-400">通知用户</dt>
|
||||
<dd className="text-gray-700 break-all">{item.notify_user_id}</dd>
|
||||
<dt className="text-gray-400">轮询间隔</dt>
|
||||
<dd className="text-gray-700">{item.poll_seconds}s</dd>
|
||||
<dt className="text-gray-400">抓取上限</dt>
|
||||
<dd className="text-gray-700">{item.fetch_limit}</dd>
|
||||
<dt className="text-gray-400">无效证书</dt>
|
||||
<dd className="text-gray-700">{item.accept_invalid_certs ? "是" : "否"}</dd>
|
||||
<dt className="text-gray-400">密码</dt>
|
||||
<dd className="text-gray-700">{item.password_configured ? "已配置" : "未配置"}</dd>
|
||||
<dt className="text-gray-400">最近检查</dt>
|
||||
<dd className="text-gray-700">{fmtTime(item.last_checked_at)}</dd>
|
||||
<dt className="text-gray-400">更新时间</dt>
|
||||
<dd className="text-gray-700">{fmtTime(item.updated_at)}</dd>
|
||||
</dl>
|
||||
{item.last_error && (
|
||||
<div className="mt-3 text-sm text-red-600 bg-red-50 border border-red-100 rounded-md px-3 py-2 break-words">
|
||||
{item.last_error}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
interface ToolParam {
|
||||
name: string;
|
||||
param_type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
path?: string;
|
||||
params: ToolParam[];
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: number;
|
||||
started_at: string;
|
||||
last_message_at: string;
|
||||
ended_at?: string;
|
||||
end_reason?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: number;
|
||||
role: string;
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface MemoryRow {
|
||||
id: number;
|
||||
content: string;
|
||||
metadata: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
interface SummaryRow {
|
||||
id: number;
|
||||
summary_type: string;
|
||||
content: string;
|
||||
started_at?: string;
|
||||
ended_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SimulatorResponse {
|
||||
success: boolean;
|
||||
has_query: boolean;
|
||||
scope_key: string;
|
||||
tools: Tool[];
|
||||
default_messages: { role: string; content: string }[];
|
||||
default_system_prompt: string;
|
||||
sessions: SessionRow[];
|
||||
messages: MessageRow[];
|
||||
memories: MemoryRow[];
|
||||
summaries: SummaryRow[];
|
||||
}
|
||||
|
||||
function toolToJson(tool: Tool): object {
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: Object.fromEntries(
|
||||
tool.params.map((p) => [p.name, { type: p.param_type, description: p.description }])
|
||||
),
|
||||
required: tool.params.filter((p) => p.required).map((p) => p.name),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ContextSimulatorPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [data, setData] = useState<SimulatorResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const channel = searchParams.get("channel") || "wechat";
|
||||
const accountId = searchParams.get("account_id") || "";
|
||||
const fromUserId = searchParams.get("from_user_id") || "";
|
||||
|
||||
// 编辑器状态
|
||||
const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadData = (values: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
fromUserId: string;
|
||||
}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params: Record<string, string> = {};
|
||||
if (values.channel) params.channel = values.channel;
|
||||
if (values.accountId) params.account_id = values.accountId;
|
||||
if (values.fromUserId) params.from_user_id = values.fromUserId;
|
||||
|
||||
apiGet<SimulatorResponse>("/v1/context/simulator", params)
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
setMessages(res.default_messages);
|
||||
setSelectedTools(new Set());
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData({ channel, accountId, fromUserId });
|
||||
}, [channel, accountId, fromUserId]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
const params = new URLSearchParams();
|
||||
const c = (form.get("channel") as string) || "";
|
||||
const a = (form.get("account_id") as string) || "";
|
||||
const f = (form.get("from_user_id") as string) || "";
|
||||
if (c) params.set("channel", c);
|
||||
if (a) params.set("account_id", a);
|
||||
if (f) params.set("from_user_id", f);
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (data) {
|
||||
setMessages(data.default_messages);
|
||||
setSelectedTools(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTool = (name: string) => {
|
||||
setSelectedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllTools = () => {
|
||||
if (data) setSelectedTools(new Set(data.tools.map((t) => t.name)));
|
||||
};
|
||||
|
||||
const deselectAllTools = () => {
|
||||
setSelectedTools(new Set());
|
||||
};
|
||||
|
||||
const buildRequestPreview = () => {
|
||||
const tools = (data?.tools || [])
|
||||
.filter((t) => selectedTools.has(t.name))
|
||||
.map(toolToJson);
|
||||
return JSON.stringify(
|
||||
{
|
||||
model: "deepseek-v4-flash",
|
||||
messages,
|
||||
tools,
|
||||
stream: false,
|
||||
extra_body: { thinking: { type: "enabled" } },
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
};
|
||||
|
||||
const requestJson = buildRequestPreview();
|
||||
const totalChars = requestJson.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-16 text-red-500">
|
||||
<p>加载失败:{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">上下文组装模拟器</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500">
|
||||
模拟上下文组装过程,查看发送给 LLM 的完整 messages 和 tools。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 查询表单 */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 mb-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-0.5">channel</label>
|
||||
<input
|
||||
name="channel"
|
||||
defaultValue={channel}
|
||||
placeholder="wechat"
|
||||
className="w-28 border border-gray-300 rounded px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-0.5">account_id</label>
|
||||
<input
|
||||
name="account_id"
|
||||
defaultValue={accountId}
|
||||
placeholder="account"
|
||||
className="w-32 border border-gray-300 rounded px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-0.5">from_user_id</label>
|
||||
<input
|
||||
name="from_user_id"
|
||||
defaultValue={fromUserId}
|
||||
placeholder="user"
|
||||
className="w-32 border border-gray-300 rounded px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-medium hover:bg-blue-700 transition"
|
||||
>
|
||||
加载真实数据
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="bg-gray-100 text-gray-700 px-4 py-1.5 rounded text-sm font-medium hover:bg-gray-200 transition border border-gray-300"
|
||||
>
|
||||
默认场景
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 数据概览 */}
|
||||
{data?.has_query && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-5 text-sm">
|
||||
<div className="font-medium text-green-800 mb-1">已加载真实数据</div>
|
||||
<div className="text-green-700 flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span>
|
||||
scope_key:{" "}
|
||||
<code className="text-xs bg-green-100 px-1 rounded">{data.scope_key}</code>
|
||||
</span>
|
||||
<span>会话 {data.sessions.length} 个</span>
|
||||
<span>消息 {data.messages.length} 条</span>
|
||||
<span>记忆 {data.memories.length} 条</span>
|
||||
<span>摘要 {data.summaries.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* 左栏:工具选择 + 消息编辑 */}
|
||||
<div className="space-y-5">
|
||||
{/* 工具选择 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h2 className="text-sm font-semibold text-gray-800">
|
||||
可用工具{" "}
|
||||
<span className="text-gray-400 font-normal">(勾选后加入 tools 数组)</span>
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAllTools}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deselectAllTools}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 max-h-80 overflow-y-auto space-y-1">
|
||||
{(data?.tools || []).map((tool) => (
|
||||
<label
|
||||
key={tool.name}
|
||||
className="flex items-start gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer border border-transparent hover:border-gray-200 transition"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 shrink-0"
|
||||
checked={selectedTools.has(tool.name)}
|
||||
onChange={() => toggleTool(tool.name)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-800">{tool.name}</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
tool.source === "内置"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-orange-100 text-orange-700"
|
||||
}`}
|
||||
>
|
||||
{tool.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{tool.description}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{tool.params.map((p) => (
|
||||
<span key={p.name} className="text-[11px] text-gray-500">
|
||||
{p.required && <span className="text-red-400">*</span>}
|
||||
{p.name}:{p.param_type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-gray-100 text-xs text-gray-400">
|
||||
已选 {selectedTools.size} / {data?.tools.length || 0} 个工具
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息编辑 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h2 className="text-sm font-semibold text-gray-800">Messages 数组</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMessages([...messages, { role: "user", content: "" }])}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
+ 添加消息
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 space-y-2 max-h-96 overflow-y-auto">
|
||||
{messages.map((msg, i) => {
|
||||
const roleColor =
|
||||
msg.role === "system"
|
||||
? "border-blue-300 bg-blue-50"
|
||||
: msg.role === "user"
|
||||
? "border-green-300 bg-green-50"
|
||||
: msg.role === "assistant"
|
||||
? "border-purple-300 bg-purple-50"
|
||||
: "border-orange-300 bg-orange-50";
|
||||
return (
|
||||
<div key={i} className={`border rounded-lg p-3 ${roleColor}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="text-xs border rounded px-1.5 py-0.5 bg-white"
|
||||
value={msg.role}
|
||||
onChange={(e) => {
|
||||
const next = [...messages];
|
||||
next[i] = { ...next[i], role: e.target.value };
|
||||
setMessages(next);
|
||||
}}
|
||||
>
|
||||
<option value="system">system</option>
|
||||
<option value="user">user</option>
|
||||
<option value="assistant">assistant</option>
|
||||
<option value="tool">tool</option>
|
||||
</select>
|
||||
<span className="text-xs text-gray-500">#{i + 1}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = [...messages];
|
||||
next.splice(i, 1);
|
||||
setMessages(next);
|
||||
}}
|
||||
className="text-xs text-red-400 hover:text-red-600"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full border rounded px-2 py-1.5 text-sm font-mono bg-white resize-y"
|
||||
rows={3}
|
||||
value={msg.content}
|
||||
onChange={(e) => {
|
||||
const next = [...messages];
|
||||
next[i] = { ...next[i], content: e.target.value };
|
||||
setMessages(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{messages.length === 0 && (
|
||||
<div className="text-sm text-gray-400 text-center py-4">
|
||||
暂无消息,点击「+ 添加消息」
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-gray-100 text-xs text-gray-400">
|
||||
共 {messages.length} 条消息,预估 {totalChars} 字符
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右栏:预览 */}
|
||||
<div className="space-y-5">
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h2 className="text-sm font-semibold text-gray-800">发送给 LLM 的完整请求</h2>
|
||||
<span className="text-xs text-gray-400">POST /v1/chat/completions</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<pre className="text-xs leading-relaxed whitespace-pre-wrap break-all font-mono bg-gray-900 text-green-400 rounded-lg p-4 max-h-[600px] overflow-auto">
|
||||
{requestJson}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-gray-100 text-xs text-gray-400">
|
||||
预估 token: {Math.ceil(totalChars / 2)}(约 1 token ≈ 2 中文字符)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-3">iAs</h1>
|
||||
<p className="text-gray-500 mb-8">智能助手服务</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-md mx-auto">
|
||||
<Link
|
||||
to="/updates"
|
||||
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
|
||||
>
|
||||
<div className="text-2xl mb-1">📋</div>
|
||||
<div className="text-sm font-medium text-gray-700">更新日志</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/mail"
|
||||
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
|
||||
>
|
||||
<div className="text-2xl mb-1">📧</div>
|
||||
<div className="text-sm font-medium text-gray-700">邮件</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/tools"
|
||||
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
|
||||
>
|
||||
<div className="text-2xl mb-1">🔧</div>
|
||||
<div className="text-sm font-medium text-gray-700">工具</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/configs"
|
||||
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
|
||||
>
|
||||
<div className="text-2xl mb-1">⚙️</div>
|
||||
<div className="text-sm font-medium text-gray-700">配置</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/context"
|
||||
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
|
||||
>
|
||||
<div className="text-2xl mb-1">🧠</div>
|
||||
<div className="text-sm font-medium text-gray-700">上下文</div>
|
||||
</Link>
|
||||
<a
|
||||
href="/web/page/index"
|
||||
className="bg-white rounded-xl border border-gray-200 p-4 hover:border-blue-400 hover:shadow-md transition text-center"
|
||||
>
|
||||
<div className="text-2xl mb-1">📄</div>
|
||||
<div className="text-sm font-medium text-gray-700">页面</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams, Link } from "react-router-dom";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
interface MailMessage {
|
||||
id: number;
|
||||
from_label: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
content_preview: string;
|
||||
mailbox: string;
|
||||
source_key: string;
|
||||
seen: boolean;
|
||||
received_at: string;
|
||||
raw_size: number;
|
||||
mail_account_id?: number;
|
||||
message_id?: string;
|
||||
uid: string;
|
||||
raw_headers?: string;
|
||||
preview_url?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface MailDetailResponse {
|
||||
success: boolean;
|
||||
message: MailMessage;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function contentIsHtml(content: string): boolean {
|
||||
const lower = content.trim().slice(0, 1024).toLowerCase();
|
||||
return (
|
||||
lower.includes("<html") ||
|
||||
lower.includes("<!doctype") ||
|
||||
lower.includes("<body") ||
|
||||
lower.includes("<div") ||
|
||||
lower.includes("<table") ||
|
||||
lower.includes("<meta") ||
|
||||
lower.includes("<head") ||
|
||||
lower.includes("<a href") ||
|
||||
lower.includes("<p ") ||
|
||||
lower.includes("<br") ||
|
||||
lower.includes("<img") ||
|
||||
lower.includes("<span")
|
||||
);
|
||||
}
|
||||
|
||||
function sandboxedEmailHtml(content: string): string {
|
||||
const parsed = new DOMParser().parseFromString(content, "text/html");
|
||||
const styles = Array.from(parsed.head.querySelectorAll("style"))
|
||||
.map((style) => style.textContent || "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const body = parsed.body.innerHTML.trim() || content;
|
||||
const styleTag = styles ? `<style>${styles}</style>` : "";
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: cid: https: http:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">${styleTag}</head><body>${body}</body></html>`;
|
||||
}
|
||||
|
||||
function renderTextContent(content: string) {
|
||||
return content.split("\n").map((line, i) =>
|
||||
line.trim() === "" ? (
|
||||
<br key={i} />
|
||||
) : (
|
||||
<p key={i} className="mb-2 last:mb-0">
|
||||
{line}
|
||||
</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function MailDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [data, setData] = useState<MailDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiGet<MailDetailResponse>(`/v1/mail/messages/${id}`)
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<h1 className="text-2xl font-bold">邮件不存在</h1>
|
||||
<p className="mt-2 text-gray-500">{error || "没有找到对应的邮件记录"}</p>
|
||||
<Link to="/mail" className="mt-4 inline-block text-blue-600 hover:text-blue-800">
|
||||
返回邮件列表
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const msg = data.message;
|
||||
const backParams = searchParams.toString();
|
||||
const backUrl = `/mail${backParams ? `?${backParams}` : ""}`;
|
||||
const subject = msg.subject || "无主题";
|
||||
const received = msg.received_at
|
||||
? new Date(msg.received_at).toLocaleString("zh-CN", { timeZone: "UTC" }) + " UTC"
|
||||
: "-";
|
||||
const htmlContent = contentIsHtml(msg.content);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 mb-5">
|
||||
<Link
|
||||
to={backUrl}
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 transition"
|
||||
>
|
||||
← 邮件列表
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 mb-4 shadow-sm">
|
||||
<h1 className="text-lg md:text-xl font-bold leading-snug break-words mb-3">{subject}</h1>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-sm text-gray-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="font-semibold text-gray-700">发件人</span>
|
||||
{msg.from_label}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="font-semibold text-gray-700">时间</span>
|
||||
{received}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="font-semibold text-gray-700">来源</span>
|
||||
{msg.source_key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="bg-white rounded-xl border border-gray-200 overflow-hidden mb-4">
|
||||
<summary className="px-4 md:px-5 py-3 cursor-pointer text-sm font-semibold text-blue-600 hover:bg-gray-50 transition select-none">
|
||||
详细信息
|
||||
</summary>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-[auto,1fr] gap-x-5 gap-y-2.5 p-4 md:p-5 pt-2 md:pt-3 border-t border-gray-100 text-sm">
|
||||
<dt className="text-gray-400 md:text-right">邮箱目录</dt>
|
||||
<dd className="text-gray-700 break-all">{msg.mailbox}</dd>
|
||||
<dt className="text-gray-400 md:text-right">状态</dt>
|
||||
<dd className="text-gray-700">{msg.seen ? "已读" : "未读"}</dd>
|
||||
<dt className="text-gray-400 md:text-right">账户</dt>
|
||||
<dd className="text-gray-700">
|
||||
{msg.mail_account_id ? `账户 #${msg.mail_account_id}` : "环境配置"}
|
||||
</dd>
|
||||
<dt className="text-gray-400 md:text-right">UID</dt>
|
||||
<dd className="text-gray-700 break-all">
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{msg.uid}</code>
|
||||
</dd>
|
||||
<dt className="text-gray-400 md:text-right">Message-ID</dt>
|
||||
<dd className="text-gray-700 break-all">
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{msg.message_id?.slice(0, 72) || "-"}
|
||||
</code>
|
||||
</dd>
|
||||
<dt className="text-gray-400 md:text-right">缓存 ID</dt>
|
||||
<dd className="text-gray-700">{msg.id}</dd>
|
||||
<dt className="text-gray-400 md:text-right">原始大小</dt>
|
||||
<dd className="text-gray-700">{formatBytes(msg.raw_size)}</dd>
|
||||
<dt className="text-gray-400 md:text-right">入库时间</dt>
|
||||
<dd className="text-gray-700">
|
||||
{msg.created_at
|
||||
? new Date(msg.created_at).toLocaleString("zh-CN", { timeZone: "UTC" }) + " UTC"
|
||||
: "-"}
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
{htmlContent ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<iframe
|
||||
sandbox=""
|
||||
srcDoc={sandboxedEmailHtml(msg.content)}
|
||||
className="w-full min-h-[420px] border-0 block bg-white"
|
||||
loading="lazy"
|
||||
title="邮件内容"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<article className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 text-sm md:text-base leading-relaxed whitespace-normal break-words">
|
||||
{renderTextContent(msg.content)}
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* 原始头信息 */}
|
||||
{msg.raw_headers && msg.raw_headers.trim() && (
|
||||
<details className="bg-white rounded-xl border border-gray-200 overflow-hidden mb-4 mt-4">
|
||||
<summary className="px-4 md:px-5 py-3 cursor-pointer text-sm font-semibold text-blue-600 hover:bg-gray-50 transition select-none">
|
||||
原始头信息
|
||||
</summary>
|
||||
<pre className="m-0 px-4 md:px-5 py-4 text-xs leading-relaxed font-mono text-gray-700 whitespace-pre-wrap break-all border-t border-gray-100 max-h-80 overflow-y-auto">
|
||||
{msg.raw_headers}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams, Link } from "react-router-dom";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
interface MailMessage {
|
||||
id: number;
|
||||
from_label: string;
|
||||
subject: string;
|
||||
content_preview: string;
|
||||
mailbox: string;
|
||||
source_key: string;
|
||||
seen: boolean;
|
||||
received_at: string;
|
||||
raw_size: number;
|
||||
}
|
||||
|
||||
interface MailListResponse {
|
||||
success: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
messages: MailMessage[];
|
||||
}
|
||||
|
||||
export function MailPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [data, setData] = useState<MailListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const page = Number(searchParams.get("page") || "1");
|
||||
const perPage = Number(searchParams.get("per_page") || "20");
|
||||
const q = searchParams.get("q") || "";
|
||||
const mailbox = searchParams.get("mailbox") || "";
|
||||
const sourceKey = searchParams.get("source_key") || "";
|
||||
const seen = searchParams.get("seen") || "";
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params: Record<string, string | number> = { page, per_page: perPage };
|
||||
if (q) params.q = q;
|
||||
if (mailbox) params.mailbox = mailbox;
|
||||
if (sourceKey) params.source_key = sourceKey;
|
||||
if (seen) params.seen = seen;
|
||||
|
||||
apiGet<MailListResponse>("/v1/mail/messages", params)
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, perPage, q, mailbox, sourceKey, seen]);
|
||||
|
||||
const updateParams = (updates: Record<string, string>) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
newParams.set(key, value);
|
||||
} else {
|
||||
newParams.delete(key);
|
||||
}
|
||||
});
|
||||
if (updates.q !== undefined || updates.mailbox !== undefined || updates.source_key !== undefined || updates.seen !== undefined) {
|
||||
newParams.set("page", "1");
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const hasFilter = q || mailbox || sourceKey || seen;
|
||||
const totalPages = data ? Math.ceil(data.total / data.per_page) : 0;
|
||||
|
||||
const formatTime = (t: string) => {
|
||||
if (!t) return "-";
|
||||
const d = new Date(t);
|
||||
return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const relativeTime = (t: string) => {
|
||||
if (!t) return "";
|
||||
const delta = Date.now() - new Date(t).getTime();
|
||||
const mins = Math.floor(delta / 60000);
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} 天前`;
|
||||
return "";
|
||||
};
|
||||
|
||||
const truncate = (s: string, max: number) => {
|
||||
if (!s) return "";
|
||||
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">📧 邮件列表</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500">
|
||||
{data
|
||||
? hasFilter
|
||||
? `筛选结果:共 ${data.total} 封,当前 ${(page - 1) * perPage + 1}–${Math.min(page * perPage, data.total)}`
|
||||
: `共 ${data.total} 封邮件`
|
||||
: "加载中..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
updateParams({
|
||||
q: (form.get("q") as string) || "",
|
||||
mailbox: (form.get("mailbox") as string) || "",
|
||||
source_key: (form.get("source_key") as string) || "",
|
||||
seen: (form.get("seen") as string) || "",
|
||||
});
|
||||
}}
|
||||
className="bg-white rounded-xl border border-gray-200 p-4 md:p-5 mb-5"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="搜索发件人、主题或正文…"
|
||||
className="w-full border border-gray-200 rounded-lg px-3.5 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
name="mailbox"
|
||||
defaultValue={mailbox}
|
||||
placeholder="目录"
|
||||
className="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
|
||||
/>
|
||||
<input
|
||||
name="source_key"
|
||||
defaultValue={sourceKey}
|
||||
placeholder="来源"
|
||||
className="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition placeholder:text-gray-400"
|
||||
/>
|
||||
<select
|
||||
name="seen"
|
||||
defaultValue={seen}
|
||||
className="w-24 md:w-28 border border-gray-200 rounded-lg px-3 py-2.5 text-sm bg-white focus:outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="false">未读</option>
|
||||
<option value="true">已读</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="per_page" value={perPage} />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center px-5 py-2.5 rounded-lg text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 transition shadow-sm"
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
{hasFilter && (
|
||||
<Link
|
||||
to="/mail"
|
||||
className="inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-600 transition"
|
||||
>
|
||||
清除
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-16 text-red-500">
|
||||
<p>加载失败:{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
{data.messages.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="text-4xl mb-3">📭</div>
|
||||
<p className="text-base">没有匹配的邮件</p>
|
||||
</div>
|
||||
) : (
|
||||
data.messages.map((msg) => (
|
||||
<Link
|
||||
key={msg.id}
|
||||
to={`/mail/messages/${msg.id}?${searchParams.toString()}`}
|
||||
className={`block bg-white rounded-xl border p-4 md:p-5 hover:border-blue-400 hover:shadow-md transition group ${
|
||||
msg.seen ? "border-gray-200" : "border-blue-200 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<h3 className="text-sm md:text-base font-semibold text-gray-900 group-hover:text-blue-700 leading-snug min-w-0 break-words">
|
||||
{msg.subject || "无主题"}
|
||||
</h3>
|
||||
<div className="shrink-0 flex items-center gap-2 pt-0.5">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium ${
|
||||
msg.seen
|
||||
? "bg-gray-100 text-gray-500"
|
||||
: "bg-blue-50 text-blue-700 font-semibold"
|
||||
}`}
|
||||
>
|
||||
{msg.seen ? "已读" : "未读"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap hidden sm:inline">
|
||||
{formatTime(msg.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1.5 mb-2.5">
|
||||
<span className="text-sm text-gray-600 truncate">{msg.from_label}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="sm:hidden text-xs text-gray-400">{formatTime(msg.received_at)}</span>
|
||||
<span className="text-xs text-gray-400">{relativeTime(msg.received_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{msg.content_preview && (
|
||||
<p className="text-xs text-gray-400 line-clamp-2 leading-relaxed">
|
||||
{truncate(msg.content_preview, 120)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-blue-50 text-blue-600">
|
||||
#{msg.id}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-gray-100 text-gray-500">
|
||||
{msg.mailbox}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-gray-100 text-gray-500">
|
||||
{msg.source_key}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<nav className="flex items-center justify-between gap-3 mt-5">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
to={`/mail?${new URLSearchParams({ ...Object.fromEntries(searchParams), page: String(page - 1) })}`}
|
||||
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition"
|
||||
>
|
||||
‹ 上一页
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">
|
||||
‹ 上一页
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-400">{page} / {totalPages}</span>
|
||||
{page < totalPages ? (
|
||||
<Link
|
||||
to={`/mail?${new URLSearchParams({ ...Object.fromEntries(searchParams), page: String(page + 1) })}`}
|
||||
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition"
|
||||
>
|
||||
下一页 ›
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-4 py-2 rounded-lg text-sm border border-gray-200 bg-white text-gray-300 cursor-default">
|
||||
下一页 ›
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
interface ToolParam {
|
||||
name: string;
|
||||
param_type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ToolItem {
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
path?: string;
|
||||
params: ToolParam[];
|
||||
required_env: string[];
|
||||
}
|
||||
|
||||
interface ToolsResponse {
|
||||
success: boolean;
|
||||
total: number;
|
||||
tools_path: string;
|
||||
builtin_tools: ToolItem[];
|
||||
capability_tools: ToolItem[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function ToolsPage() {
|
||||
const [data, setData] = useState<ToolsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<ToolsResponse>("/v1/tools")
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-16 text-red-500">
|
||||
<p>加载失败:{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const renderToolCard = (item: ToolItem) => (
|
||||
<article
|
||||
key={item.name}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 md:p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900 break-words">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-blue-50 text-blue-700">
|
||||
{item.source}
|
||||
</span>
|
||||
</div>
|
||||
{item.path && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 text-gray-500">
|
||||
{item.path}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.params.length > 0 ? (
|
||||
<div className="mt-3">
|
||||
{item.params.map((param) => (
|
||||
<div
|
||||
key={param.name}
|
||||
className="py-1.5 border-t border-gray-100 first:border-t-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded">
|
||||
{param.name}
|
||||
</code>
|
||||
<span className="text-xs text-gray-400">{param.param_type}</span>
|
||||
{param.required ? (
|
||||
<span className="text-[11px] font-medium text-red-600 bg-red-50 px-1.5 py-0.5 rounded">
|
||||
必填
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
可选
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{param.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">无参数</p>
|
||||
)}
|
||||
{item.required_env.length > 0 && (
|
||||
<div className="mt-3 text-xs text-gray-500">
|
||||
环境变量:{" "}
|
||||
{item.required_env.map((env) => (
|
||||
<code
|
||||
key={env}
|
||||
className="bg-gray-100 px-1.5 py-0.5 rounded mr-1"
|
||||
>
|
||||
{env}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">工具列表</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500">
|
||||
共 {data.total} 个工具;YAML 工具目录:
|
||||
<code className="text-xs bg-gray-200 text-gray-700 px-1.5 py-0.5 rounded">
|
||||
{data.tools_path}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.warnings.length > 0 && (
|
||||
<section className="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 mb-5 text-sm">
|
||||
<div className="font-semibold mb-1">部分工具定义未能加载</div>
|
||||
<ul>
|
||||
{data.warnings.map((w, i) => (
|
||||
<li key={i} className="ml-4 list-disc">
|
||||
{w}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">内置工具</h2>
|
||||
<span className="text-sm text-gray-400">{data.builtin_tools.length} 个</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.builtin_tools.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">
|
||||
暂无工具
|
||||
</div>
|
||||
) : (
|
||||
data.builtin_tools.map(renderToolCard)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">YAML 工具</h2>
|
||||
<span className="text-sm text-gray-400">{data.capability_tools.length} 个</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.capability_tools.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-5 text-sm text-gray-400">
|
||||
暂无工具
|
||||
</div>
|
||||
) : (
|
||||
data.capability_tools.map(renderToolCard)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
interface UpgradeLog {
|
||||
version: string;
|
||||
released_at: string;
|
||||
title: string;
|
||||
description: string;
|
||||
changes: string[];
|
||||
}
|
||||
|
||||
export function UpdatesPage() {
|
||||
const { "*": splat } = useParams();
|
||||
const version = splat || undefined;
|
||||
|
||||
if (version) {
|
||||
return <UpdateDetail version={version} />;
|
||||
}
|
||||
return <UpdateList />;
|
||||
}
|
||||
|
||||
function UpdateList() {
|
||||
const [logs, setLogs] = useState<UpgradeLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ success: boolean; logs: UpgradeLog[] }>("/v1/updates")
|
||||
.then((data) => setLogs(data.logs || []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-16 text-red-500">
|
||||
<p>加载失败:{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||
更新日志
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500">共 {logs.length} 条更新</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="text-4xl mb-3">📭</div>
|
||||
<p className="text-base">暂无更新日志</p>
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<Link
|
||||
key={log.version}
|
||||
to={`/updates/${log.version}`}
|
||||
className="block bg-white rounded-xl border border-gray-200 p-4 md:p-5 hover:border-blue-400 hover:shadow-md transition"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
v{log.version} {log.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 leading-relaxed">
|
||||
{log.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{log.released_at}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDetail({ version }: { version: string }) {
|
||||
const [log, setLog] = useState<UpgradeLog | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ success: boolean; log: UpgradeLog }>(`/v1/updates/${version}`)
|
||||
.then((data) => setLog(data.log))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [version]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !log) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<h1 className="text-2xl font-bold">更新日志不存在</h1>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{error || "没有找到对应版本的更新日志"}
|
||||
</p>
|
||||
<Link
|
||||
to="/updates"
|
||||
className="mt-4 inline-block text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
返回更新日志列表
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<Link
|
||||
to="/updates"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 transition"
|
||||
>
|
||||
← 更新日志列表
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 mb-4 shadow-sm">
|
||||
<h1 className="text-lg md:text-xl font-bold leading-snug mb-2">
|
||||
v{log.version} {log.title}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">{log.released_at}</p>
|
||||
<p className="mt-3 text-gray-700">{log.description}</p>
|
||||
{log.changes && log.changes.length > 0 && (
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
{log.changes.map((change, i) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-0.5">•</span>
|
||||
<span>{change}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
interface WebPage {
|
||||
id: number;
|
||||
slug: string;
|
||||
page_type: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
content: string;
|
||||
source_label?: string | null;
|
||||
metadata: string;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
interface WebPageResponse {
|
||||
success: boolean;
|
||||
page: WebPage;
|
||||
}
|
||||
|
||||
interface MetadataItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const metadataLabels: Record<string, string> = {
|
||||
from: "发件人",
|
||||
subject: "主题",
|
||||
message_id: "邮件 ID",
|
||||
mailbox: "邮箱目录",
|
||||
uid: "UID",
|
||||
reminder: "提醒",
|
||||
received_at: "收件时间",
|
||||
};
|
||||
|
||||
export function GenericWebPageDetailPage() {
|
||||
const { pageType, slug } = useParams<{ pageType: string; slug: string }>();
|
||||
|
||||
if (!pageType || !slug) {
|
||||
return <PageError message="页面地址不完整" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WebPageDetail
|
||||
endpoint={`/v1/web/pages/${encodeURIComponent(pageType)}/${encodeURIComponent(slug)}`}
|
||||
backUrl="/"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailWebPageDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
|
||||
if (!slug) {
|
||||
return <PageError message="邮件页面地址不完整" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WebPageDetail
|
||||
endpoint={`/v1/web/mail/${encodeURIComponent(slug)}`}
|
||||
backUrl="/mail"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownWebPageDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
|
||||
if (!slug) {
|
||||
return <PageError message="Markdown 页面地址不完整" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WebPageDetail
|
||||
endpoint={`/v1/web/pages/markdown/${encodeURIComponent(slug)}`}
|
||||
backUrl="/"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebPageDetail({ endpoint, backUrl }: { endpoint: string; backUrl: string }) {
|
||||
const [page, setPage] = useState<WebPage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiGet<WebPageResponse>(endpoint)
|
||||
.then((data) => setPage(data.page))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [endpoint]);
|
||||
|
||||
const metadata = useMemo(() => parseMetadata(page?.metadata), [page?.metadata]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !page) {
|
||||
return <PageError message={error || "没有找到对应的内容页面"} />;
|
||||
}
|
||||
|
||||
const contentFormat = pageContentFormat(page, metadata);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<Link
|
||||
to={backUrl}
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 transition"
|
||||
>
|
||||
← 返回
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 mb-4 shadow-sm">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold bg-blue-50 text-blue-700 mb-3">
|
||||
{page.page_type}
|
||||
</span>
|
||||
<h1 className="text-lg md:text-xl font-bold leading-snug break-words">
|
||||
{page.title}
|
||||
</h1>
|
||||
{page.summary && (
|
||||
<p className="mt-2 text-sm text-gray-500 leading-relaxed">{page.summary}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3 text-sm text-gray-400">
|
||||
{page.source_label && <span>来源:{page.source_label}</span>}
|
||||
{page.created_at && <span>创建时间:{formatTime(page.created_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metadata.length > 0 && (
|
||||
<section className="bg-white rounded-xl border border-gray-200 mb-4">
|
||||
<dl className="grid grid-cols-1 md:grid-cols-[120px,1fr] gap-x-4 gap-y-2 p-4 text-sm">
|
||||
{metadata.map((item) => (
|
||||
<div key={item.key} className="contents">
|
||||
<dt className="text-gray-400">{item.label}</dt>
|
||||
<dd className="text-gray-700 break-all">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<article className="bg-white rounded-xl border border-gray-200 p-4 md:p-6 text-sm md:text-base leading-relaxed whitespace-normal break-words">
|
||||
{contentFormat === "markdown" ? (
|
||||
<MarkdownContent content={page.content} />
|
||||
) : (
|
||||
renderPlainText(page.content)
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<h1 className="text-2xl font-bold">页面不存在</h1>
|
||||
<p className="mt-2 text-gray-500">{message}</p>
|
||||
<Link to="/" className="mt-4 inline-block text-blue-600 hover:text-blue-800">
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseMetadata(raw?: string | null): MetadataItem[] {
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
const value = JSON.parse(raw) as unknown;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
|
||||
return Object.entries(value)
|
||||
.map(([key, item]) => {
|
||||
const text = metadataValueToText(item);
|
||||
if (!text) return null;
|
||||
return {
|
||||
key,
|
||||
label: metadataLabels[key] || key,
|
||||
value: text,
|
||||
};
|
||||
})
|
||||
.filter((item): item is MetadataItem => item !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function metadataValueToText(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) return null;
|
||||
if (typeof value === "object" && Object.keys(value).length === 0) return null;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function renderPlainText(content: string) {
|
||||
return content.split("\n").map((line, i) =>
|
||||
line.trim() === "" ? (
|
||||
<br key={i} />
|
||||
) : (
|
||||
<p key={i} className="mb-2 last:mb-0">
|
||||
{line}
|
||||
</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownContent({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline underline-offset-2"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold mt-0 mb-4 leading-tight">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-bold mt-7 mb-3 leading-tight">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 leading-tight">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => <p className="my-3 leading-7">{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-3 list-disc pl-6 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="my-3 list-decimal pl-6 space-y-1">{children}</ol>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-4 border-l-4 border-gray-300 pl-4 text-gray-600">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className) || String(children).includes("\n");
|
||||
return (
|
||||
<code
|
||||
className={
|
||||
isBlock
|
||||
? "font-mono text-gray-100"
|
||||
: "rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[0.92em] text-gray-900"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-4 overflow-x-auto rounded-lg bg-gray-950 p-4 text-sm leading-6 text-gray-100">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-gray-200 px-3 py-2 align-top">{children}</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function pageContentFormat(page: WebPage, metadata: MetadataItem[]): "markdown" | "plain" {
|
||||
if (page.page_type === "markdown" || page.page_type === "md") return "markdown";
|
||||
|
||||
const format = metadata
|
||||
.find((item) => item.key === "content_format" || item.key === "format")
|
||||
?.value.trim()
|
||||
.toLowerCase();
|
||||
return format === "markdown" || format === "md" ? "markdown" : "plain";
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { timeZone: "UTC" }) + " UTC";
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/v1": "http://127.0.0.1:9003",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user