refactor: split each page into common + web/h5 device specific parts for mobile adaptation

main
sp mac bookpro 2605 2026-06-11 13:18:18 +08:00
parent 0832930bcb
commit 423fc9531f
24 changed files with 4081 additions and 2011 deletions

View File

@ -1,385 +1,28 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Card, List, Modal, Form, Input, Select, Tag, Space, Popconfirm, App as AntApp, Tooltip, Alert } from 'antd'; import { useMcpPanelLogic } from './McpPanel/McpPanelLogic';
import { McpAPI, McpServer, McpStatus } from '../api'; import McpPanelWeb from './McpPanel/components/McpPanelWeb';
import McpPanelH5 from './McpPanel/components/McpPanelH5';
interface Props {
agentId: string; interface Props {
} agentId: string;
}
const PRESETS: { label: string; description: string; config: any }[] = [
{ const isMobileDevice = () => {
label: 'filesystem (官方 NPM)', if (typeof window === 'undefined') return false;
description: '本地文件系统操作(读、列目录)。请把最后一个参数改成你要暴露给智能体的目录绝对路径。', return window.innerWidth < 768;
config: { };
mcpServers: {
filesystem: { export default function McpPanel({ agentId }: Props) {
command: 'npx', const logic = useMcpPanelLogic({ agentId });
args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/your/dir'] const [isMobile, setIsMobile] = useState(isMobileDevice());
}
} useEffect(() => {
} const handleResize = () => {
}, setIsMobile(isMobileDevice());
{ };
label: 'fetch (官方 NPM)', window.addEventListener('resize', handleResize);
description: '抓取网页/URL 内容', return () => window.removeEventListener('resize', handleResize);
config: { }, []);
mcpServers: {
fetch: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-fetch'] } return isMobile ? <McpPanelH5 agentId={agentId} logic={logic} /> : <McpPanelWeb agentId={agentId} logic={logic} />;
} }
}
},
{
label: 'sequential-thinking (官方 NPM)',
description: '辅助思维链推理',
config: {
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking']
}
}
}
}
];
export default function McpPanel({ agentId }: Props) {
const { message } = AntApp.useApp();
const [servers, setServers] = useState<McpServer[]>([]);
const [statusList, setStatusList] = useState<McpStatus[]>([]);
const [statusLoading, setStatusLoading] = useState(false);
const [editing, setEditing] = useState<McpServer | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importJson, setImportJson] = useState('');
const refresh = async () => {
const list = await McpAPI.list(agentId);
setServers(list);
};
const refreshStatus = async () => {
setStatusLoading(true);
try {
const s = await McpAPI.status(agentId);
setStatusList(s);
} catch (e: any) {
message.error('状态获取失败:' + (e?.message ?? e));
} finally {
setStatusLoading(false);
}
};
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentId]);
const handleSave = async (values: any) => {
try {
// args 字符串 → 数组
let args: string[] = [];
if (typeof values.argsText === 'string') {
args = values.argsText
.split(/\r?\n/)
.map((s: string) => s.trim())
.filter(Boolean);
}
let env: Record<string, string> = {};
if (values.envText) {
try {
env = JSON.parse(values.envText);
} catch {
message.error('env 必须是合法 JSON 对象');
return;
}
}
const payload = {
name: values.name,
transport: values.transport,
command: values.command || '',
args,
env,
url: values.url || ''
};
if (editing) {
await McpAPI.update(agentId, editing.id, payload);
message.success('已更新');
} else {
await McpAPI.create(agentId, payload);
message.success('已创建');
}
setEditing(null);
setCreateOpen(false);
refresh();
} catch (e: any) {
message.error('保存失败:' + (e?.message ?? e));
}
};
const handleImport = async () => {
let parsed: any;
try {
parsed = JSON.parse(importJson);
} catch {
message.error('JSON 解析失败');
return;
}
try {
const r = await McpAPI.importJSON(agentId, parsed, false);
message.success(`已导入 ${r.imported} 个 MCP Server`);
setImportOpen(false);
setImportJson('');
refresh();
} catch (e: any) {
message.error('导入失败:' + (e?.message ?? e));
}
};
return (
<Card
title={
<Space>
<span>🔌 MCP Servers</span>
<Tag>{servers.length} </Tag>
</Space>
}
extra={
<Space>
<Button onClick={refreshStatus} loading={statusLoading}>
🔄
</Button>
<Button onClick={() => setImportOpen(true)}>📥 JSON</Button>
<Button
type="primary"
onClick={() => {
setEditing(null);
setCreateOpen(true);
}}
>
</Button>
</Space>
}
>
<Alert
style={{ marginBottom: 12 }}
type="info"
showIcon
message="MCP (Model Context Protocol) 让智能体能调用外部工具。本机首次连接 stdio 类型会启动子进程,可能需要数秒。"
/>
<List
dataSource={servers}
locale={{ emptyText: '尚未配置 MCP Server' }}
renderItem={(s) => {
const status = statusList.find((x) => x.id === s.id);
return (
<List.Item
actions={[
<Button
key="edit"
size="small"
onClick={() => {
setEditing(s);
setCreateOpen(true);
}}
>
</Button>,
<Popconfirm
key="del"
title="确认删除该 MCP Server"
onConfirm={async () => {
await McpAPI.remove(agentId, s.id);
message.success('已删除');
refresh();
}}
>
<Button danger size="small">
</Button>
</Popconfirm>
]}
>
<List.Item.Meta
title={
<Space>
<Tag color={s.transport === 'stdio' ? 'blue' : 'green'}>{s.transport}</Tag>
<span>{s.name}</span>
{!s.enabled && <Tag></Tag>}
{status?.error && (
<Tooltip title={status.error}>
<Tag color="error"></Tag>
</Tooltip>
)}
{status && !status.error && (
<Tag color="success">{status.toolCount} </Tag>
)}
</Space>
}
description={
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<code style={{ fontSize: 12, color: '#6b7280' }}>
{s.transport === 'stdio'
? `${s.command} ${s.args.join(' ')}`
: s.url}
</code>
{status?.tools && status.tools.length > 0 && (
<Space wrap size={4}>
{status.tools.slice(0, 8).map((t) => (
<Tooltip key={t.name} title={t.description}>
<Tag color="purple" style={{ fontSize: 11 }}>
{t.name}
</Tag>
</Tooltip>
))}
{status.tools.length > 8 && <Tag>+{status.tools.length - 8}</Tag>}
</Space>
)}
</Space>
}
/>
</List.Item>
);
}}
/>
<Modal
open={createOpen}
title={editing ? '编辑 MCP Server' : '新增 MCP Server'}
width={680}
onCancel={() => {
setCreateOpen(false);
setEditing(null);
}}
footer={null}
destroyOnHidden
>
<McpForm
initial={editing}
onSubmit={handleSave}
onCancel={() => {
setCreateOpen(false);
setEditing(null);
}}
/>
</Modal>
<Modal
open={importOpen}
title="📥 导入 mcpServers JSON"
width={760}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
okText="导入"
cancelText="取消"
>
<Alert
type="info"
showIcon
message="兼容 Claude Desktop / Cursor 的 mcpServers 配置格式。"
style={{ marginBottom: 12 }}
/>
<Space style={{ marginBottom: 8 }} wrap>
{PRESETS.map((p) => (
<Tooltip key={p.label} title={p.description}>
<Button size="small" onClick={() => setImportJson(JSON.stringify(p.config, null, 2))}>
{p.label}
</Button>
</Tooltip>
))}
</Space>
<Input.TextArea
value={importJson}
onChange={(e) => setImportJson(e.target.value)}
placeholder={`{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
}
}
}`}
autoSize={{ minRows: 12, maxRows: 20 }}
style={{ fontFamily: 'Consolas, Menlo, monospace', fontSize: 13 }}
/>
</Modal>
</Card>
);
}
function McpForm({
initial,
onSubmit,
onCancel
}: {
initial: McpServer | null;
onSubmit: (values: any) => void;
onCancel: () => void;
}) {
const [form] = Form.useForm();
const [transport, setTransport] = useState<'stdio' | 'sse' | 'http'>(initial?.transport ?? 'stdio');
useEffect(() => {
form.setFieldsValue({
name: initial?.name ?? '',
transport: initial?.transport ?? 'stdio',
command: initial?.command ?? '',
argsText: (initial?.args ?? []).join('\n'),
envText: initial?.env ? JSON.stringify(initial.env, null, 2) : '',
url: initial?.url ?? ''
});
setTransport(initial?.transport ?? 'stdio');
}, [initial, form]);
return (
<Form form={form} layout="vertical" onFinish={onSubmit}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="例如 filesystem / playwright" />
</Form.Item>
<Form.Item name="transport" label="Transport" rules={[{ required: true }]}>
<Select
options={[
{ value: 'stdio', label: 'stdio (本机进程)' },
{ value: 'sse', label: 'SSE (远程)' },
{ value: 'http', label: 'HTTP (Streamable)' }
]}
onChange={(v) => setTransport(v as any)}
/>
</Form.Item>
{transport === 'stdio' ? (
<>
<Form.Item name="command" label="Command" rules={[{ required: true }]} extra="如 npx / node / python">
<Input placeholder="npx" />
</Form.Item>
<Form.Item name="argsText" label="Args (每行一个)">
<Input.TextArea
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={'-y\n@modelcontextprotocol/server-filesystem\n/path/to/dir'}
style={{ fontFamily: 'Consolas, monospace', fontSize: 13 }}
/>
</Form.Item>
<Form.Item name="envText" label="Env (JSON 对象)">
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 6 }}
placeholder={'{ "API_KEY": "xxx" }'}
style={{ fontFamily: 'Consolas, monospace', fontSize: 13 }}
/>
</Form.Item>
</>
) : (
<Form.Item name="url" label="URL" rules={[{ required: true }]}>
<Input placeholder="https://example.com/mcp" />
</Form.Item>
)}
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form>
);
}

View File

@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
import { McpAPI, McpServer, McpStatus } from '../../api';
export const PRESETS: { label: string; description: string; config: any }[] = [
{
label: 'filesystem (官方 NPM)',
description: '本地文件系统操作(读、列目录)。请把最后一个参数改成你要暴露给智能体的目录绝对路径。',
config: {
mcpServers: {
filesystem: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/your/dir'],
},
},
},
},
{
label: 'fetch (官方 NPM)',
description: '抓取网页/URL 内容',
config: {
mcpServers: {
fetch: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-fetch'] },
},
},
},
{
label: 'sequential-thinking (官方 NPM)',
description: '辅助思维链推理',
config: {
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
},
},
];
export function useMcpPanelLogic({ agentId }: { agentId: string }) {
const [servers, setServers] = useState<McpServer[]>([]);
const [statusList, setStatusList] = useState<McpStatus[]>([]);
const [statusLoading, setStatusLoading] = useState(false);
const [editing, setEditing] = useState<McpServer | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importJson, setImportJson] = useState('');
const refresh = async () => {
const list = await McpAPI.list(agentId);
setServers(list);
};
const refreshStatus = async () => {
setStatusLoading(true);
try {
const s = await McpAPI.status(agentId);
setStatusList(s);
} catch (e: any) {
console.error('获取状态失败', e);
} finally {
setStatusLoading(false);
}
};
useEffect(() => {
refresh();
}, [agentId]);
const handleSave = async (values: any) => {
// args 字符串 → 数组
let args: string[] = [];
if (typeof values.argsText === 'string') {
args = values.argsText
.split(/\r?\n/)
.map((s: string) => s.trim())
.filter(Boolean);
}
let env: Record<string, string> = {};
if (values.envText) {
try {
env = JSON.parse(values.envText);
} catch {
return { success: false, error: 'env 必须是合法 JSON 对象' };
}
}
const payload = {
name: values.name,
transport: values.transport,
command: values.command || '',
args,
env,
url: values.url || '',
};
try {
if (editing) {
await McpAPI.update(agentId, editing.id, payload);
} else {
await McpAPI.create(agentId, payload);
}
setEditing(null);
setCreateOpen(false);
refresh();
return { success: true };
} catch (e: any) {
return { success: false, error: e?.message ?? '保存失败' };
}
};
const handleImport = async () => {
let parsed: any;
try {
parsed = JSON.parse(importJson);
} catch {
return { success: false, error: 'JSON 解析失败' };
}
try {
const r = await McpAPI.importJSON(agentId, parsed, false);
setImportOpen(false);
setImportJson('');
refresh();
return { success: true, imported: r.imported };
} catch (e: any) {
return { success: false, error: e?.message ?? '导入失败' };
}
};
const handleDelete = async (id: string) => {
await McpAPI.remove(agentId, id);
refresh();
};
const openCreate = () => {
setEditing(null);
setCreateOpen(true);
};
const openEdit = (s: McpServer) => {
setEditing(s);
setCreateOpen(true);
};
const closeCreate = () => {
setCreateOpen(false);
setEditing(null);
};
return {
servers,
statusList,
statusLoading,
editing,
createOpen,
importOpen,
importJson,
PRESETS,
refresh,
refreshStatus,
handleSave,
handleImport,
handleDelete,
openCreate,
openEdit,
closeCreate,
setImportOpen,
setImportJson,
};
}
export type McpPanelLogicOutput = ReturnType<typeof useMcpPanelLogic>;

View File

@ -0,0 +1,338 @@
import { useEffect } from 'react';
import { Button, Card, List, Modal, App as AntApp, Space, Alert, Tag, Popconfirm, Tooltip } from 'antd';
import type { McpServer } from '../../../api';
import type { McpPanelLogicOutput } from '../McpPanelLogic';
interface Props {
agentId: string;
logic: McpPanelLogicOutput;
}
export default function McpPanelH5({ agentId, logic }: Props) {
const { message } = AntApp.useApp();
const {
servers,
statusList,
statusLoading,
createOpen,
importOpen,
importJson,
PRESETS,
handleImport,
handleDelete,
openCreate,
openEdit,
closeCreate,
setImportOpen,
setImportJson,
refreshStatus,
} = logic;
return (
<Card
className="h5-mcp-card"
title={
<Space>
<span>🔌 MCP Servers</span>
<Tag>{servers.length} </Tag>
</Space>
}
extra={
<Button
type="primary"
size="small"
onClick={openCreate}
style={{ borderRadius: 8 }}
>
</Button>
}
style={{ borderRadius: 12 }}
>
<Alert
style={{ marginBottom: 10, fontSize: 12 }}
type="info"
showIcon
message="MCP 让智能体能调用外部工具。首次连接可能需要数秒。"
/>
<Space wrap style={{ marginBottom: 12 }}>
<Button size="small" onClick={refreshStatus} loading={statusLoading} style={{ borderRadius: 8 }}>
🔄
</Button>
<Button size="small" onClick={() => setImportOpen(true)} style={{ borderRadius: 8 }}>
📥
</Button>
</Space>
<List
dataSource={servers}
locale={{ emptyText: '尚未配置 MCP Server' }}
renderItem={(s) => {
const status = statusList.find((x) => x.id === s.id);
return (
<List.Item
style={{ padding: '10px 0' }}
actions={[
<Button
key="edit"
size="small"
onClick={() => openEdit(s)}
style={{ borderRadius: 6 }}
>
</Button>,
<Popconfirm
key="del"
title="确认删除?"
onConfirm={async () => {
await handleDelete(s.id);
message.success('已删除');
}}
>
<Button danger size="small" style={{ borderRadius: 6 }}>
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
title={
<Space wrap>
<Tag color={s.transport === 'stdio' ? 'blue' : 'green'} style={{ fontSize: 11 }}>
{s.transport}
</Tag>
<span style={{ fontSize: 14 }}>{s.name}</span>
{!s.enabled && <Tag style={{ fontSize: 11 }}></Tag>}
{status?.error && (
<Tooltip title={status.error}>
<Tag color="error" style={{ fontSize: 11 }}></Tag>
</Tooltip>
)}
{status && !status.error && (
<Tag color="success" style={{ fontSize: 11 }}>{status.toolCount} </Tag>
)}
</Space>
}
description={
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 4 }}>
<code style={{ fontSize: 11, color: '#6b7280', wordBreak: 'break-all' }}>
{s.transport === 'stdio'
? `${s.command} ${s.args.join(' ')}`
: s.url}
</code>
{status?.tools && status.tools.length > 0 && (
<Space wrap size={2}>
{status.tools.slice(0, 4).map((t) => (
<Tooltip key={t.name} title={t.description}>
<Tag color="purple" style={{ fontSize: 10, margin: 0 }}>
{t.name}
</Tag>
</Tooltip>
))}
{status.tools.length > 4 && <Tag style={{ fontSize: 10 }}>+{status.tools.length - 4}</Tag>}
</Space>
)}
</Space>
}
/>
</List.Item>
);
}}
/>
<Modal
open={createOpen}
title={logic.editing ? '编辑 MCP Server' : '新增 MCP Server'}
width="95%"
onCancel={closeCreate}
footer={null}
destroyOnHidden
>
<McpFormH5
initial={logic.editing}
onSubmit={async (values) => {
const result = await logic.handleSave(values);
if (result.success) {
message.success(logic.editing ? '已更新' : '已创建');
} else {
message.error(result.error);
}
}}
onCancel={closeCreate}
/>
</Modal>
<Modal
open={importOpen}
title="📥 导入 MCP JSON"
width="95%"
onCancel={() => setImportOpen(false)}
onOk={async () => {
const result = await handleImport();
if (result.success) {
message.success(`已导入 ${result.imported}`);
} else {
message.error(result.error);
}
}}
okText="导入"
cancelText="取消"
>
<Alert
type="info"
showIcon
message="兼容 Claude Desktop 格式。点击预设快速填入。"
style={{ marginBottom: 10, fontSize: 12 }}
/>
<Space style={{ marginBottom: 8 }} wrap>
{PRESETS.map((p) => (
<Tooltip key={p.label} title={p.description}>
<Button size="small" onClick={() => setImportJson(JSON.stringify(p.config, null, 2))}>
{p.label}
</Button>
</Tooltip>
))}
</Space>
<textarea
value={importJson}
onChange={(e) => setImportJson(e.target.value)}
placeholder={`{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
}
}
}`}
style={{
width: '100%',
minHeight: 160,
maxHeight: 240,
fontFamily: 'Consolas, Menlo, monospace',
fontSize: 12,
padding: 8,
borderRadius: 6,
border: '1px solid #d9d9d9',
}}
/>
</Modal>
</Card>
);
}
function McpFormH5({
initial,
onSubmit,
onCancel,
}: {
initial: McpServer | null;
onSubmit: (values: any) => void;
onCancel: () => void;
}) {
const { useForm } = AntApp;
const [form] = useForm();
const [transport, setTransport] = useState<'stdio' | 'sse' | 'http'>(initial?.transport ?? 'stdio');
useEffect(() => {
form.setFieldsValue({
name: initial?.name ?? '',
transport: initial?.transport ?? 'stdio',
command: initial?.command ?? '',
argsText: (initial?.args ?? []).join('\n'),
envText: initial?.env ? JSON.stringify(initial.env, null, 2) : '',
url: initial?.url ?? '',
});
setTransport(initial?.transport ?? 'stdio');
}, [initial, form]);
return (
<form onSubmit={(e) => {
e.preventDefault();
onSubmit(form.getFieldsValue());
}}>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, display: 'block' }}></label>
<input
{...form.getFieldProps('name')}
placeholder="例如 filesystem"
style={{ width: '100%', padding: '8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, display: 'block' }}>Transport</label>
<select
value={transport}
onChange={(e) => setTransport(e.target.value as any)}
style={{ width: '100%', padding: '8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
>
<option value="stdio">stdio ()</option>
<option value="sse">SSE ()</option>
<option value="http">HTTP (Streamable)</option>
</select>
</div>
{transport === 'stdio' ? (
<>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, display: 'block' }}>Command</label>
<input
{...form.getFieldProps('command')}
placeholder="npx"
style={{ width: '100%', padding: '8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
/>
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}> npx / node / python</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, display: 'block' }}>Args ()</label>
<textarea
{...form.getFieldProps('argsText')}
placeholder={'-y\n@modelcontextprotocol/server-filesystem\n/path/to/dir'}
style={{
width: '100%',
minHeight: 60,
fontFamily: 'Consolas, monospace',
fontSize: 12,
padding: 8,
borderRadius: 6,
border: '1px solid #d9d9d9',
}}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, display: 'block' }}>Env (JSON )</label>
<textarea
{...form.getFieldProps('envText')}
placeholder={'{ "API_KEY": "xxx" }'}
style={{
width: '100%',
minHeight: 40,
fontFamily: 'Consolas, monospace',
fontSize: 12,
padding: 8,
borderRadius: 6,
border: '1px solid #d9d9d9',
}}
/>
</div>
</>
) : (
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, display: 'block' }}>URL</label>
<input
{...form.getFieldProps('url')}
placeholder="https://example.com/mcp"
style={{ width: '100%', padding: '8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
/>
</div>
)}
<Space style={{ justifyContent: 'flex-end', width: '100%', marginTop: 16 }}>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</form>
);
}

View File

@ -0,0 +1,310 @@
import { useEffect } from 'react';
import { Button, Card, List, Modal, App as AntApp, Space, Alert, Tag, Popconfirm, Tooltip } from 'antd';
import type { McpServer } from '../../../api';
import type { McpPanelLogicOutput } from '../McpPanelLogic';
interface Props {
agentId: string;
logic: McpPanelLogicOutput;
}
export default function McpPanelWeb({ agentId, logic }: Props) {
const { message } = AntApp.useApp();
const {
servers,
statusList,
statusLoading,
createOpen,
importOpen,
importJson,
PRESETS,
handleImport,
handleDelete,
openCreate,
openEdit,
closeCreate,
setImportOpen,
setImportJson,
refreshStatus,
} = logic;
return (
<Card
title={
<Space>
<span>🔌 MCP Servers</span>
<Tag>{servers.length} </Tag>
</Space>
}
extra={
<Space>
<Button onClick={refreshStatus} loading={statusLoading}>
🔄
</Button>
<Button onClick={() => setImportOpen(true)}>📥 JSON</Button>
<Button
type="primary"
onClick={openCreate}
>
</Button>
</Space>
}
>
<Alert
style={{ marginBottom: 12 }}
type="info"
showIcon
message="MCP (Model Context Protocol) 让智能体能调用外部工具。本机首次连接 stdio 类型会启动子进程,可能需要数秒。"
/>
<List
dataSource={servers}
locale={{ emptyText: '尚未配置 MCP Server' }}
renderItem={(s) => {
const status = statusList.find((x) => x.id === s.id);
return (
<List.Item
actions={[
<Button
key="edit"
size="small"
onClick={() => openEdit(s)}
>
</Button>,
<Popconfirm
key="del"
title="确认删除该 MCP Server"
onConfirm={async () => {
await handleDelete(s.id);
message.success('已删除');
}}
>
<Button danger size="small">
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
title={
<Space>
<Tag color={s.transport === 'stdio' ? 'blue' : 'green'}>{s.transport}</Tag>
<span>{s.name}</span>
{!s.enabled && <Tag></Tag>}
{status?.error && (
<Tooltip title={status.error}>
<Tag color="error"></Tag>
</Tooltip>
)}
{status && !status.error && (
<Tag color="success">{status.toolCount} </Tag>
)}
</Space>
}
description={
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<code style={{ fontSize: 12, color: '#6b7280' }}>
{s.transport === 'stdio'
? `${s.command} ${s.args.join(' ')}`
: s.url}
</code>
{status?.tools && status.tools.length > 0 && (
<Space wrap size={4}>
{status.tools.slice(0, 8).map((t) => (
<Tooltip key={t.name} title={t.description}>
<Tag color="purple" style={{ fontSize: 11 }}>
{t.name}
</Tag>
</Tooltip>
))}
{status.tools.length > 8 && <Tag>+{status.tools.length - 8}</Tag>}
</Space>
)}
</Space>
}
/>
</List.Item>
);
}}
/>
<Modal
open={createOpen}
title={logic.editing ? '编辑 MCP Server' : '新增 MCP Server'}
width={680}
onCancel={closeCreate}
footer={null}
destroyOnHidden
>
<McpForm
initial={logic.editing}
onSubmit={async (values) => {
const result = await logic.handleSave(values);
if (result.success) {
message.success(logic.editing ? '已更新' : '已创建');
} else {
message.error(result.error);
}
}}
onCancel={closeCreate}
/>
</Modal>
<Modal
open={importOpen}
title="📥 导入 mcpServers JSON"
width={760}
onCancel={() => setImportOpen(false)}
onOk={async () => {
const result = await handleImport();
if (result.success) {
message.success(`已导入 ${result.imported} 个 MCP Server`);
} else {
message.error(result.error);
}
}}
okText="导入"
cancelText="取消"
>
<Alert
type="info"
showIcon
message="兼容 Claude Desktop / Cursor 的 mcpServers 配置格式。"
style={{ marginBottom: 12 }}
/>
<Space style={{ marginBottom: 8 }} wrap>
{PRESETS.map((p) => (
<Tooltip key={p.label} title={p.description}>
<Button size="small" onClick={() => setImportJson(JSON.stringify(p.config, null, 2))}>
{p.label}
</Button>
</Tooltip>
))}
</Space>
<textarea
value={importJson}
onChange={(e) => setImportJson(e.target.value)}
placeholder={`{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
}
}
}`}
style={{
width: '100%',
minHeight: 200,
maxHeight: 300,
fontFamily: 'Consolas, Menlo, monospace',
fontSize: 13,
padding: 8,
borderRadius: 6,
border: '1px solid #d9d9d9',
}}
/>
</Modal>
</Card>
);
}
function McpForm({
initial,
onSubmit,
onCancel,
}: {
initial: McpServer | null;
onSubmit: (values: any) => void;
onCancel: () => void;
}) {
const [form] = AntApp.useApp().form;
const [transport, setTransport] = useState<'stdio' | 'sse' | 'http'>(initial?.transport ?? 'stdio');
useEffect(() => {
form.setFieldsValue({
name: initial?.name ?? '',
transport: initial?.transport ?? 'stdio',
command: initial?.command ?? '',
argsText: (initial?.args ?? []).join('\n'),
envText: initial?.env ? JSON.stringify(initial.env, null, 2) : '',
url: initial?.url ?? '',
});
setTransport(initial?.transport ?? 'stdio');
}, [initial, form]);
return (
<form onSubmit={(e) => {
e.preventDefault();
onSubmit(form.getFieldsValue());
}}>
<div className="form-item">
<label></label>
<input
{...form.getFieldProps('name')}
placeholder="例如 filesystem / playwright"
style={{ width: '100%' }}
/>
</div>
<div className="form-item">
<label>Transport</label>
<select
value={transport}
onChange={(e) => setTransport(e.target.value as any)}
style={{ width: '100%' }}
>
<option value="stdio">stdio ()</option>
<option value="sse">SSE ()</option>
<option value="http">HTTP (Streamable)</option>
</select>
</div>
{transport === 'stdio' ? (
<>
<div className="form-item">
<label>Command</label>
<input
{...form.getFieldProps('command')}
placeholder="npx"
style={{ width: '100%' }}
/>
<div className="form-extra"> npx / node / python</div>
</div>
<div className="form-item">
<label>Args ()</label>
<textarea
{...form.getFieldProps('argsText')}
placeholder={'-y\n@modelcontextprotocol/server-filesystem\n/path/to/dir'}
style={{ width: '100%', minHeight: 80, fontFamily: 'Consolas, monospace', fontSize: 13 }}
/>
</div>
<div className="form-item">
<label>Env (JSON )</label>
<textarea
{...form.getFieldProps('envText')}
placeholder={'{ "API_KEY": "xxx" }'}
style={{ width: '100%', minHeight: 60, fontFamily: 'Consolas, monospace', fontSize: 13 }}
/>
</div>
</>
) : (
<div className="form-item">
<label>URL</label>
<input
{...form.getFieldProps('url')}
placeholder="https://example.com/mcp"
style={{ width: '100%' }}
/>
</div>
)}
<Space style={{ justifyContent: 'flex-end', width: '100%', marginTop: 16 }}>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</form>
);
}

View File

@ -1,317 +1,24 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { import { useAgentListLogic } from './AgentList/AgentListLogic';
ArrowRightOutlined, import AgentListWeb from './AgentList/components/AgentListWeb';
CompassOutlined, import AgentListH5 from './AgentList/components/AgentListH5';
DeleteOutlined,
EditOutlined, const isMobileDevice = () => {
MessageOutlined, if (typeof window === 'undefined') return false;
RobotOutlined return window.innerWidth < 768;
} from '@ant-design/icons'; };
import { Button, Col, Row, Empty, Popconfirm, App as AntApp, Tag, Space } from 'antd';
import { Link, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { Agent, AgentAPI } from '../api';
export default function AgentList() { export default function AgentList() {
const [list, setList] = useState<Agent[]>([]); const logic = useAgentListLogic();
const [loading, setLoading] = useState(false); const [isMobile, setIsMobile] = useState(isMobileDevice());
const navigate = useNavigate();
const { message } = AntApp.useApp();
const load = async () => {
setLoading(true);
try {
setList(await AgentAPI.list());
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
load(); const handleResize = () => {
setIsMobile(isMobileDevice());
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
const handleDelete = async (id: string) => { return isMobile ? <AgentListH5 logic={logic} /> : <AgentListWeb logic={logic} />;
await AgentAPI.remove(id);
message.success('已删除');
load();
};
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
const getModelLabel = (value: unknown) => {
if (Array.isArray(value)) {
const names = value
.map((item: any) => (typeof item === 'string' ? item : item?.name))
.map((v) => String(v || '').trim())
.filter(Boolean);
return names.join('、');
}
if (typeof value !== 'string') {
return '';
}
const raw = value.trim();
if (!raw) {
return '';
}
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const names = parsed
.map((item: any) => (typeof item === 'string' ? item : item?.name))
.map((v) => String(v || '').trim())
.filter(Boolean);
return names.join('、');
}
} catch {
}
if (raw.includes(',')) {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join('、');
}
return raw;
};
const publicCount = useMemo(() => list.filter((a) => a.visibility === 'public').length, [list]);
const teamCount = useMemo(() => list.filter((a) => a.visibility === 'team').length, [list]);
return (
<div className="page-container">
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 48%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 22
}}
>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<RobotOutlined style={{ color: 'var(--color-brand)' }} />
Agent
</div>
<h2 className="page-title" style={{ marginBottom: 10 }}>
</h2>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
AI 广
</div>
</div>
<Button
type="primary"
size="large"
icon={<CompassOutlined />}
onClick={() => navigate('/marketplace')}
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
>
广
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{[
{ label: '已创建智能体', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '公开可分享', value: publicCount, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
{ label: '团队协作中', value: teamCount, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600
}}
>
</span>
</div>
</div>
))}
</div>
</div>
{!loading && list.length === 0 ? (
<div className="empty-state">
<Empty description="你还没有任何智能体">
<Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 10 }}>
广
</Button>
</Empty>
</div>
) : (
<Row gutter={[18, 18]}>
{list.map((a) => (
<Col xs={24} sm={12} md={8} lg={6} key={a.id}>
<div
className="agent-card"
style={{
borderRadius: 20,
padding: 20,
minHeight: 292,
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
className="avatar"
style={{ background: a.avatar || 'var(--gradient-brand)', borderRadius: '50%', overflow: 'hidden', width: 54, height: 54 }}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 17, color: 'var(--color-text)', marginBottom: 4 }}>{a.name}</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>
{dayjs(a.updated_at).format('YYYY-MM-DD')}
</div>
</div>
</div>
<div
style={{
marginTop: 16,
padding: '16px 16px 18px',
borderRadius: 16,
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)'
}}
>
<div className="desc" style={{ minHeight: 66, fontSize: 13.5, lineHeight: 1.7 }}>
{a.description || '还没有填写描述,可以补充这个智能体适合解决什么问题。'}
</div>
</div>
<Space size={6} wrap style={{ marginTop: 14 }}>
{a.visibility === 'public' && (
<Tag bordered={false} style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{a.visibility === 'team' && (
<Tag bordered={false} style={{ background: 'var(--color-info-soft)', color: 'var(--color-info)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{a.visibility === 'private' && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{getModelLabel(a.model) && (
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, maxWidth: '100%' }}>
<span style={{ display: 'inline-block', maxWidth: 190, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{getModelLabel(a.model)}
</span>
</Tag>
)}
{(a.fork_count ?? 0) > 0 && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
Fork {a.fork_count}
</Tag>
)}
</Space>
<div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
<Link to={`/chat/${a.id}`} style={{ flex: 1 }}>
<Button type="primary" block icon={<MessageOutlined />} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button>
</Link>
<Link to={`/agents/${a.id}`} style={{ flex: 1 }}>
<Button block icon={<EditOutlined />} style={{ borderRadius: 12, height: 40 }}>
</Button>
</Link>
<Popconfirm
title="确定删除该智能体?"
description="将删除其知识库与对话记录"
onConfirm={() => handleDelete(a.id)}
okText="删除"
cancelText="取消"
>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 12, width: 40, height: 40 }} />
</Popconfirm>
</div>
</div>
</Col>
))}
</Row>
)}
{list.length > 0 && (
<div
style={{
marginTop: 24,
borderRadius: 20,
padding: '18px 20px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap'
}}
>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', marginBottom: 4 }}>
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
广
</div>
</div>
<Button type="text" icon={<ArrowRightOutlined />} onClick={() => navigate('/marketplace')} style={{ color: 'var(--color-brand)', fontWeight: 600 }}>
广
</Button>
</div>
)}
</div>
);
} }

View File

@ -0,0 +1,81 @@
import { useEffect, useMemo, useState } from 'react';
import { Agent, AgentAPI } from '../../api';
export function useAgentListLogic() {
const [list, setList] = useState<Agent[]>([]);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
setList(await AgentAPI.list());
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleDelete = async (id: string) => {
await AgentAPI.remove(id);
load();
};
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
const getModelLabel = (value: unknown): string => {
if (Array.isArray(value)) {
const names = value
.map((item: any) => (typeof item === 'string' ? item : item?.name))
.map((v) => String(v || '').trim())
.filter(Boolean);
return names.join('、');
}
if (typeof value !== 'string') return '';
const raw = value.trim();
if (!raw) return '';
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const names = parsed
.map((item: any) => (typeof item === 'string' ? item : item?.name))
.map((v) => String(v || '').trim())
.filter(Boolean);
return names.join('、');
}
} catch {
// ignore
}
if (raw.includes(',')) {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join('、');
}
return raw;
};
const publicCount = useMemo(() => list.filter((a) => a.visibility === 'public').length, [list]);
const teamCount = useMemo(() => list.filter((a) => a.visibility === 'team').length, [list]);
const stats = [
{ label: '已创建智能体', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '公开可分享', value: publicCount, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
{ label: '团队协作中', value: teamCount, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
];
return {
list,
loading,
stats,
load,
handleDelete,
isImageUrl,
getModelLabel,
};
}
export type AgentListLogicOutput = ReturnType<typeof useAgentListLogic>;

View File

@ -0,0 +1,261 @@
import {
ArrowRightOutlined,
CompassOutlined,
DeleteOutlined,
EditOutlined,
MessageOutlined,
RobotOutlined,
} from '@ant-design/icons';
import { Button, Col, Row, Empty, Popconfirm, App as AntApp, Tag, Space } from 'antd';
import { Link, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import type { Agent } from '../../../api';
import type { AgentListLogicOutput } from '../AgentListLogic';
interface Props {
logic: AgentListLogicOutput;
}
export default function AgentListH5({ logic }: Props) {
const { message } = AntApp.useApp();
const navigate = useNavigate();
const { list, loading, stats, handleDelete, isImageUrl, getModelLabel } = logic;
return (
<div className="page-container h5-page-container">
<div
style={{
borderRadius: 16,
padding: '20px 16px 18px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 48%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
marginBottom: 16,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
flexWrap: 'wrap',
marginBottom: 16,
}}
>
<div style={{ width: '100%' }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 11,
fontWeight: 600,
marginBottom: 12,
}}
>
<RobotOutlined style={{ color: 'var(--color-brand)', fontSize: 12 }} />
Agent
</div>
<h2 className="page-title h5-page-title" style={{ marginBottom: 8, fontSize: 22 }}>
</h2>
<div className="page-subtitle h5-page-subtitle" style={{ marginTop: 0, fontSize: 13, lineHeight: 1.6 }}>
AI
</div>
</div>
<Button
type="primary"
size="middle"
icon={<CompassOutlined />}
onClick={() => navigate('/marketplace')}
style={{ borderRadius: 10, height: 40, padding: '0 16px', fontWeight: 600, width: '100%' }}
>
广
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gap: 10 }}>
{stats.map((item) => (
<div
key={item.label}
style={{
borderRadius: 12,
padding: '12px 14px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)',
}}
>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '3px 6px',
background: item.tone,
color: item.color,
fontSize: 10,
fontWeight: 600,
}}
>
</span>
</div>
</div>
))}
</div>
</div>
{!loading && list.length === 0 ? (
<div className="empty-state h5-empty-state">
<Empty description="你还没有任何智能体">
<Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 8 }}>
广
</Button>
</Empty>
</div>
) : (
<Row gutter={[12, 12]}>
{list.map((a) => (
<Col xs={24} sm={24} md={12} key={a.id}>
<div
className="agent-card h5-agent-card"
style={{
borderRadius: 14,
padding: 16,
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 6px 16px rgba(15, 23, 42, 0.045)',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div
className="avatar"
style={{ background: a.avatar || 'var(--gradient-brand)', borderRadius: '50%', overflow: 'hidden', width: 46, height: 46 }}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text)', marginBottom: 3 }}>{a.name}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)' }}>
{dayjs(a.updated_at).format('YYYY-MM-DD')}
</div>
</div>
</div>
<div
style={{
marginTop: 12,
padding: '12px 12px 14px',
borderRadius: 12,
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)',
}}
>
<div className="desc" style={{ minHeight: 50, fontSize: 12.5, lineHeight: 1.6 }}>
{a.description || '还没有填写描述,可以补充这个智能体适合解决什么问题。'}
</div>
</div>
<Space size={4} wrap style={{ marginTop: 10 }}>
{a.visibility === 'public' && (
<Tag bordered={false} style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0, fontSize: 11 }}>
</Tag>
)}
{a.visibility === 'team' && (
<Tag bordered={false} style={{ background: 'var(--color-info-soft)', color: 'var(--color-info)', borderRadius: 999, margin: 0, fontSize: 11 }}>
</Tag>
)}
{a.visibility === 'private' && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0, fontSize: 11 }}>
</Tag>
)}
{getModelLabel(a.model) && (
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, maxWidth: '100%', fontSize: 11 }}>
<span style={{ display: 'inline-block', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{getModelLabel(a.model)}
</span>
</Tag>
)}
{(a.fork_count ?? 0) > 0 && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0, fontSize: 11 }}>
Fork {a.fork_count}
</Tag>
)}
</Space>
<div style={{ display: 'flex', gap: 6, marginTop: 'auto', paddingTop: 12, borderTop: '1px solid var(--color-border)' }}>
<Link to={`/chat/${a.id}`} style={{ flex: 1 }}>
<Button type="primary" block icon={<MessageOutlined />} style={{ borderRadius: 8, height: 36, fontWeight: 600, fontSize: 13 }}>
</Button>
</Link>
<Link to={`/agents/${a.id}`} style={{ flex: 1 }}>
<Button block icon={<EditOutlined />} style={{ borderRadius: 8, height: 36, fontSize: 13 }}>
</Button>
</Link>
<Popconfirm
title="确定删除该智能体?"
description="将删除其知识库与对话记录"
onConfirm={() => {
handleDelete(a.id);
message.success('已删除');
}}
okText="删除"
cancelText="取消"
>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 8, width: 36, height: 36, padding: 0 }} />
</Popconfirm>
</div>
</div>
</Col>
))}
</Row>
)}
{list.length > 0 && (
<div
style={{
marginTop: 16,
borderRadius: 14,
padding: '14px 16px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ width: '100%' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', marginBottom: 3 }}>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
广
</div>
</div>
<Button type="text" icon={<ArrowRightOutlined />} onClick={() => navigate('/marketplace')} style={{ color: 'var(--color-brand)', fontWeight: 600, width: '100%', textAlign: 'left' }}>
广
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,262 @@
import {
ArrowRightOutlined,
CompassOutlined,
DeleteOutlined,
EditOutlined,
MessageOutlined,
RobotOutlined,
} from '@ant-design/icons';
import { Button, Col, Row, Empty, Popconfirm, App as AntApp, Tag, Space } from 'antd';
import { Link, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import type { Agent } from '../../../api';
import type { AgentListLogicOutput } from '../AgentListLogic';
interface Props {
logic: AgentListLogicOutput;
}
export default function AgentListWeb({ logic }: Props) {
const { message } = AntApp.useApp();
const navigate = useNavigate();
const { list, loading, stats, handleDelete, isImageUrl, getModelLabel } = logic;
return (
<div className="page-container">
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 48%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 22,
}}
>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16,
}}
>
<RobotOutlined style={{ color: 'var(--color-brand)' }} />
Agent
</div>
<h2 className="page-title" style={{ marginBottom: 10 }}>
</h2>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
AI 广
</div>
</div>
<Button
type="primary"
size="large"
icon={<CompassOutlined />}
onClick={() => navigate('/marketplace')}
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
>
广
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{stats.map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)',
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600,
}}
>
</span>
</div>
</div>
))}
</div>
</div>
{!loading && list.length === 0 ? (
<div className="empty-state">
<Empty description="你还没有任何智能体">
<Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 10 }}>
广
</Button>
</Empty>
</div>
) : (
<Row gutter={[18, 18]}>
{list.map((a) => (
<Col xs={24} sm={12} md={8} lg={6} key={a.id}>
<div
className="agent-card"
style={{
borderRadius: 20,
padding: 20,
minHeight: 292,
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
className="avatar"
style={{ background: a.avatar || 'var(--gradient-brand)', borderRadius: '50%', overflow: 'hidden', width: 54, height: 54 }}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 17, color: 'var(--color-text)', marginBottom: 4 }}>{a.name}</div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>
{dayjs(a.updated_at).format('YYYY-MM-DD')}
</div>
</div>
</div>
<div
style={{
marginTop: 16,
padding: '16px 16px 18px',
borderRadius: 16,
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%)',
border: '1px solid rgba(148, 163, 184, 0.14)',
}}
>
<div className="desc" style={{ minHeight: 66, fontSize: 13.5, lineHeight: 1.7 }}>
{a.description || '还没有填写描述,可以补充这个智能体适合解决什么问题。'}
</div>
</div>
<Space size={6} wrap style={{ marginTop: 14 }}>
{a.visibility === 'public' && (
<Tag bordered={false} style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{a.visibility === 'team' && (
<Tag bordered={false} style={{ background: 'var(--color-info-soft)', color: 'var(--color-info)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{a.visibility === 'private' && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
</Tag>
)}
{getModelLabel(a.model) && (
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, maxWidth: '100%' }}>
<span style={{ display: 'inline-block', maxWidth: 190, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{getModelLabel(a.model)}
</span>
</Tag>
)}
{(a.fork_count ?? 0) > 0 && (
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
Fork {a.fork_count}
</Tag>
)}
</Space>
<div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
<Link to={`/chat/${a.id}`} style={{ flex: 1 }}>
<Button type="primary" block icon={<MessageOutlined />} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button>
</Link>
<Link to={`/agents/${a.id}`} style={{ flex: 1 }}>
<Button block icon={<EditOutlined />} style={{ borderRadius: 12, height: 40 }}>
</Button>
</Link>
<Popconfirm
title="确定删除该智能体?"
description="将删除其知识库与对话记录"
onConfirm={() => {
handleDelete(a.id);
message.success('已删除');
}}
okText="删除"
cancelText="取消"
>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 12, width: 40, height: 40 }} />
</Popconfirm>
</div>
</div>
</Col>
))}
</Row>
)}
{list.length > 0 && (
<div
style={{
marginTop: 24,
borderRadius: 20,
padding: '18px 20px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap',
}}
>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', marginBottom: 4 }}>
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
广
</div>
</div>
<Button type="text" icon={<ArrowRightOutlined />} onClick={() => navigate('/marketplace')} style={{ color: 'var(--color-brand)', fontWeight: 600 }}>
广
</Button>
</div>
)}
</div>
);
}

View File

@ -1,272 +1,24 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Col, Row, Empty, Button, Tag, Space, App as AntApp, Input, Spin } from 'antd'; import { useMarketplacePageLogic } from './MarketplacePage/MarketplacePageLogic';
import { useNavigate } from 'react-router-dom'; import MarketplacePageWeb from './MarketplacePage/components/MarketplacePageWeb';
import { MarketplaceAPI, MarketplaceAgent } from '../api'; import MarketplacePageH5 from './MarketplacePage/components/MarketplacePageH5';
import { PlusOutlined, SearchOutlined, CompassOutlined, FireOutlined } from '@ant-design/icons';
export default function MarketplacePage() {
const [list, setList] = useState<MarketplaceAgent[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
const navigate = useNavigate();
const { message } = AntApp.useApp();
const load = async () => {
setLoading(true);
try {
setList(await MarketplaceAPI.list());
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const filtered = q
? list.filter(
(a) =>
a.name.includes(q) ||
a.description?.includes(q) ||
(a.ownerName || '').includes(q)
)
: list;
const handleFork = async (a: MarketplaceAgent) => {
try {
const r = await MarketplaceAPI.fork(a.id);
message.success(`已复制到「${r.name}`);
navigate(`/agents/${r.id}`);
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '复制失败');
}
};
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
return (
<div>
<div className="page-hero">
<div style={{ maxWidth: 1240, margin: '0 auto' }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
borderRadius: 999,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 500,
marginBottom: 18
}}
>
<CompassOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="hero-title"> AI </h1>
<p className="hero-subtitle">
</p>
</div>
</div>
<div className="page-container" style={{ paddingTop: 28 }}> const isMobileDevice = () => {
<div if (typeof window === 'undefined') return false;
style={{ return window.innerWidth < 768;
display: 'flex', };
justifyContent: 'space-between',
marginBottom: 28,
gap: 20,
alignItems: 'center',
flexWrap: 'wrap'
}}
>
<div style={{ flex: 1, minWidth: 280, maxWidth: 520 }}>
<Input
placeholder="搜索智能体名称、描述或作者..."
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ height: 44, borderRadius: 12 }}
allowClear
/>
</div>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => navigate('/agents/new')}
style={{
height: 44,
padding: '0 24px',
borderRadius: 12,
fontWeight: 600
}}
>
</Button>
</div>
{loading ? ( export default function MarketplacePage() {
<div style={{ padding: '60px 0', textAlign: 'center' }}> const logic = useMarketplacePageLogic();
<Spin size="large" /> const [isMobile, setIsMobile] = useState(isMobileDevice());
</div>
) : (
<Row gutter={[20, 20]}>
{/* Create New Card (First Item) */}
{!q && (
<Col xs={24} sm={12} md={8} lg={6}>
<div onClick={() => navigate('/agents/new')} className="create-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div className="create-icon">
<PlusOutlined style={{ fontSize: 24, color: '#0891b2' }} />
</div>
</div>
<div style={{ marginTop: 16 }}> useEffect(() => {
<div style={{ fontWeight: 700, fontSize: 17, color: 'var(--color-text)', marginBottom: 4 }}> const handleResize = () => {
setIsMobile(isMobileDevice());
</div> };
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 12 }}> window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
</div> }, []);
<div className="desc" style={{ minHeight: 44 }}>
AI
</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 16 }}> return isMobile ? <MarketplacePageH5 logic={logic} /> : <MarketplacePageWeb logic={logic} />;
<Button
type="default"
block
style={{
height: 40,
borderRadius: 10,
fontWeight: 600,
borderStyle: 'dashed'
}}
>
</Button>
</div>
</div>
</Col>
)}
{filtered.map((a) => (
<Col xs={24} sm={12} md={8} lg={6} key={a.id}>
<div className="agent-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div
style={{
width: 52,
height: 52,
borderRadius: '50%',
background: a.avatar || 'var(--gradient-brand)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 22,
boxShadow: 'var(--shadow-sm)',
overflow: 'hidden'
}}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
{a.fork_count > 10 && (
<Tag
bordered={false}
icon={<FireOutlined />}
style={{
borderRadius: 999,
margin: 0,
background: 'var(--color-warning-soft)',
color: 'var(--color-warning)'
}}
>
</Tag>
)}
</div>
<div style={{ marginTop: 16 }}>
<div
style={{
fontWeight: 700,
fontSize: 17,
color: 'var(--color-text)',
marginBottom: 4,
letterSpacing: '-0.01em'
}}
>
{a.name}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 12 }}>
by {a.ownerName || '匿名作者'}
</div>
<div className="desc" style={{ minHeight: 44 }}>{a.description || '暂无详细描述'}</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
<Space size={4} wrap style={{ marginBottom: 16 }}>
{a.kbCount > 0 && (
<Tag
bordered={false}
style={{
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
borderRadius: 999
}}
>
📚 {a.kbCount}
</Tag>
)}
{a.skillCount > 0 && (
<Tag
bordered={false}
style={{
background: 'var(--color-success-soft)',
color: 'var(--color-success)',
borderRadius: 999
}}
>
🛠 {a.skillCount}
</Tag>
)}
</Space>
<Button
type="default"
block
onClick={() => handleFork(a)}
style={{
height: 40,
borderRadius: 10,
fontWeight: 600
}}
>
📥
</Button>
</div>
</div>
</Col>
))}
</Row>
)}
{filtered.length === 0 && !loading && (
<Empty description="没有找到匹配的智能体" style={{ marginTop: 80 }} />
)}
</div>
</div>
);
} }

View File

@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { App as AntApp } from 'antd';
import { useNavigate } from 'react-router-dom';
import { MarketplaceAPI, MarketplaceAgent } from '../../api';
export function useMarketplacePageLogic() {
const [list, setList] = useState<MarketplaceAgent[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
const navigate = useNavigate();
const { message } = AntApp.useApp();
const load = async () => {
setLoading(true);
try {
setList(await MarketplaceAPI.list());
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const filtered = q
? list.filter(
(a) =>
a.name.includes(q) ||
a.description?.includes(q) ||
(a.ownerName || '').includes(q)
)
: list;
const handleFork = async (a: MarketplaceAgent) => {
try {
const r = await MarketplaceAPI.fork(a.id);
message.success(`已复制到「${r.name}`);
navigate(`/agents/${r.id}`);
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '复制失败');
}
};
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
return {
list,
loading,
q,
filtered,
setQ,
load,
handleFork,
isImageUrl,
};
}
export type MarketplacePageLogicOutput = ReturnType<typeof useMarketplacePageLogic>;

View File

@ -0,0 +1,243 @@
import { PlusOutlined, SearchOutlined, CompassOutlined, FireOutlined } from '@ant-design/icons';
import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd';
import { useNavigate } from 'react-router-dom';
import type { MarketplaceAgent } from '../../../api';
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
interface Props {
logic: MarketplacePageLogicOutput;
}
export default function MarketplacePageH5({ logic }: Props) {
const navigate = useNavigate();
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
return (
<div className="h5-marketplace-page">
<div className="page-hero h5-page-hero" style={{ padding: '20px 16px' }}>
<div>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 999,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
fontSize: 11,
fontWeight: 500,
marginBottom: 12,
}}
>
<CompassOutlined style={{ color: 'var(--color-brand)', fontSize: 12 }} />
</div>
<h1 className="hero-title h5-hero-title" style={{ fontSize: 28, marginBottom: 8 }}>
<br />AI
</h1>
<p className="hero-subtitle h5-hero-subtitle" style={{ fontSize: 14, lineHeight: 1.6 }}>
</p>
</div>
</div>
<div className="page-container h5-page-container" style={{ paddingTop: 20, paddingLeft: 12, paddingRight: 12 }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
marginBottom: 20,
}}
>
<div style={{ width: '100%' }}>
<Input
placeholder="搜索智能体名称、描述或作者..."
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ height: 40, borderRadius: 10 }}
allowClear
/>
</div>
<Button
type="primary"
size="middle"
icon={<PlusOutlined />}
onClick={() => navigate('/agents/new')}
style={{
height: 40,
width: '100%',
borderRadius: 10,
fontWeight: 600,
}}
>
</Button>
</div>
{loading ? (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<Spin size="large" />
</div>
) : (
<Row gutter={[12, 12]}>
{/* Create New Card - always show on H5 */}
<Col xs={24} sm={24} key="create-new">
<div onClick={() => navigate('/agents/new')} className="create-card h5-create-card" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div className="create-icon" style={{ width: 44, height: 44 }}>
<PlusOutlined style={{ fontSize: 20, color: '#0891b2' }} />
</div>
</div>
<div style={{ marginTop: 12 }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text)', marginBottom: 4 }}>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>
</div>
<div className="desc" style={{ minHeight: 36, fontSize: 12 }}>
AI
</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 12 }}>
<Button
type="default"
block
style={{
height: 36,
borderRadius: 8,
fontWeight: 600,
borderStyle: 'dashed',
}}
>
</Button>
</div>
</div>
</Col>
{filtered.map((a) => (
<Col xs={24} sm={24} key={a.id}>
<div className="agent-card h5-agent-card" style={{ padding: 14, borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: '50%',
background: a.avatar || 'var(--gradient-brand)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 18,
boxShadow: 'var(--shadow-sm)',
overflow: 'hidden',
}}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
{a.fork_count > 10 && (
<Tag
bordered={false}
icon={<FireOutlined />}
style={{
borderRadius: 999,
margin: 0,
background: 'var(--color-warning-soft)',
color: 'var(--color-warning)',
fontSize: 11,
}}
>
</Tag>
)}
</div>
<div style={{ marginTop: 12 }}>
<div
style={{
fontWeight: 700,
fontSize: 15,
color: 'var(--color-text)',
marginBottom: 4,
letterSpacing: '-0.01em',
}}
>
{a.name}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>
by {a.ownerName || '匿名作者'}
</div>
<div className="desc" style={{ minHeight: 36, fontSize: 12 }}>{a.description || '暂无详细描述'}</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 12 }}>
<Space size={4} wrap style={{ marginBottom: 12 }}>
{a.kbCount > 0 && (
<Tag
bordered={false}
style={{
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
borderRadius: 999,
fontSize: 11,
margin: 0,
}}
>
📚 {a.kbCount}
</Tag>
)}
{a.skillCount > 0 && (
<Tag
bordered={false}
style={{
background: 'var(--color-success-soft)',
color: 'var(--color-success)',
borderRadius: 999,
fontSize: 11,
margin: 0,
}}
>
🛠 {a.skillCount}
</Tag>
)}
</Space>
<Button
type="default"
block
onClick={() => handleFork(a)}
style={{
height: 36,
borderRadius: 8,
fontWeight: 600,
}}
>
📥
</Button>
</div>
</div>
</div>
))}
</Row>
)}
{filtered.length === 0 && !loading && (
<Empty description="没有找到匹配的智能体" style={{ marginTop: 40 }} />
)}
</div>
</div>
);
}

View File

@ -0,0 +1,240 @@
import { PlusOutlined, SearchOutlined, CompassOutlined, FireOutlined } from '@ant-design/icons';
import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd';
import { useNavigate } from 'react-router-dom';
import type { MarketplaceAgent } from '../../../api';
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
interface Props {
logic: MarketplacePageLogicOutput;
}
export default function MarketplacePageWeb({ logic }: Props) {
const navigate = useNavigate();
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
return (
<div>
<div className="page-hero">
<div style={{ maxWidth: 1240, margin: '0 auto' }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
borderRadius: 999,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 500,
marginBottom: 18,
}}
>
<CompassOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="hero-title"> AI </h1>
<p className="hero-subtitle">
</p>
</div>
</div>
<div className="page-container" style={{ paddingTop: 28 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 28,
gap: 20,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div style={{ flex: 1, minWidth: 280, maxWidth: 520 }}>
<Input
placeholder="搜索智能体名称、描述或作者..."
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ height: 44, borderRadius: 12 }}
allowClear
/>
</div>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => navigate('/agents/new')}
style={{
height: 44,
padding: '0 24px',
borderRadius: 12,
fontWeight: 600,
}}
>
</Button>
</div>
{loading ? (
<div style={{ padding: '60px 0', textAlign: 'center' }}>
<Spin size="large" />
</div>
) : (
<Row gutter={[20, 20]}>
{/* Create New Card (First Item) */}
{!q && (
<Col xs={24} sm={12} md={8} lg={6}>
<div onClick={() => navigate('/agents/new')} className="create-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div className="create-icon">
<PlusOutlined style={{ fontSize: 24, color: '#0891b2' }} />
</div>
</div>
<div style={{ marginTop: 16 }}>
<div style={{ fontWeight: 700, fontSize: 17, color: 'var(--color-text)', marginBottom: 4 }}>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 12 }}>
</div>
<div className="desc" style={{ minHeight: 44 }}>
AI
</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
<Button
type="default"
block
style={{
height: 40,
borderRadius: 10,
fontWeight: 600,
borderStyle: 'dashed',
}}
>
</Button>
</div>
</div>
</Col>
)}
{filtered.map((a) => (
<Col xs={24} sm={12} md={8} lg={6} key={a.id}>
<div className="agent-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div
style={{
width: 52,
height: 52,
borderRadius: '50%',
background: a.avatar || 'var(--gradient-brand)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 22,
boxShadow: 'var(--shadow-sm)',
overflow: 'hidden',
}}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
{a.fork_count > 10 && (
<Tag
bordered={false}
icon={<FireOutlined />}
style={{
borderRadius: 999,
margin: 0,
background: 'var(--color-warning-soft)',
color: 'var(--color-warning)',
}}
>
</Tag>
)}
</div>
<div style={{ marginTop: 16 }}>
<div
style={{
fontWeight: 700,
fontSize: 17,
color: 'var(--color-text)',
marginBottom: 4,
letterSpacing: '-0.01em',
}}
>
{a.name}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 12 }}>
by {a.ownerName || '匿名作者'}
</div>
<div className="desc" style={{ minHeight: 44 }}>{a.description || '暂无详细描述'}</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
<Space size={4} wrap style={{ marginBottom: 16 }}>
{a.kbCount > 0 && (
<Tag
bordered={false}
style={{
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
borderRadius: 999,
}}
>
📚 {a.kbCount}
</Tag>
)}
{a.skillCount > 0 && (
<Tag
bordered={false}
style={{
background: 'var(--color-success-soft)',
color: 'var(--color-success)',
borderRadius: 999,
}}
>
🛠 {a.skillCount}
</Tag>
)}
</Space>
<Button
type="default"
block
onClick={() => handleFork(a)}
style={{
height: 40,
borderRadius: 10,
fontWeight: 600,
}}
>
📥
</Button>
</div>
</div>
</Col>
))}
</Row>
)}
{filtered.length === 0 && !loading && (
<Empty description="没有找到匹配的智能体" style={{ marginTop: 80 }} />
)}
</div>
</div>
);
}

View File

@ -1,399 +1,24 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Card, Empty, Input, Select, Space, Spin, Tag, Form, message } from 'antd'; import { usePointsMallPageLogic } from './PointsMallPageLogic';
import { SearchOutlined } from '@ant-design/icons'; import PointsMallPageWeb from './components/PointsMallPageWeb';
import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../../api'; import PointsMallPageH5 from './components/PointsMallPageH5';
import { MOCK_OVERVIEW, MOCK_PRODUCTS } from './mocks';
import type { ExchangeFormValues, SortKey } from './types'; const isMobileDevice = () => {
import ExchangeModal from './components/ExchangeModal'; if (typeof window === 'undefined') return false;
import ConfirmExchangeModal from './components/ConfirmExchangeModal'; return window.innerWidth < 768;
};
export default function PointsMallPage() { export default function PointsMallPage() {
const [overviewLoading, setOverviewLoading] = useState(false); const logic = usePointsMallPageLogic();
const [productsLoading, setProductsLoading] = useState(false); const [isMobile, setIsMobile] = useState(isMobileDevice());
const [overview, setOverview] = useState<PointsMallOverview | null>(null);
const [categories, setCategories] = useState<PointsMallCategory[]>([]);
const [categoryId, setCategoryId] = useState<string>('all');
const [q, setQ] = useState('');
const [sort, setSort] = useState<SortKey>('popular');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(24);
const [productsRes, setProductsRes] = useState<PointsMallProductsResponse | null>(null);
const [exchangeModalVisible, setExchangeModalVisible] = useState(false);
const [confirmModalVisible, setConfirmModalVisible] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<PointsMallProduct | null>(null);
const [exchangeQuantity, setExchangeQuantity] = useState(1);
const [pendingOrderId, setPendingOrderId] = useState<string | null>(null);
const [pendingExpiresAt, setPendingExpiresAt] = useState<string | null>(null);
const [exchangeLoading, setExchangeLoading] = useState(false);
const [form] = Form.useForm<ExchangeFormValues>();
const exchangePrepareInFlightRef = useRef(false);
const loadOverview = async () => {
setOverviewLoading(true);
try {
const [meRes, categoriesRes, announcementsRes, bannersRes, promoEntriesRes] = await Promise.allSettled([
PointsMallAPI.me(),
PointsMallAPI.categories(),
PointsMallAPI.announcements(),
PointsMallAPI.banners(),
PointsMallAPI.promoEntries()
]);
const me = meRes.status === 'fulfilled' ? meRes.value : { points: 0, level: 'Lv.0' };
const cats = categoriesRes.status === 'fulfilled' ? categoriesRes.value : MOCK_OVERVIEW.categories;
const announcements = announcementsRes.status === 'fulfilled' ? announcementsRes.value : MOCK_OVERVIEW.announcements;
const banners = bannersRes.status === 'fulfilled' ? bannersRes.value : MOCK_OVERVIEW.banners;
const promoEntries = promoEntriesRes.status === 'fulfilled' ? promoEntriesRes.value : MOCK_OVERVIEW.promoEntries;
setOverview({ me, categories: cats, announcements, banners, promoEntries });
setCategories(cats);
if (!cats?.some((c) => c.id === categoryId) && cats?.[0]?.id) {
setCategoryId(cats[0].id);
}
if (meRes.status === 'rejected') {
message.error('获取积分信息失败,请稍后重试');
}
} catch {
message.error('获取积分信息失败,请稍后重试');
setOverview({ ...MOCK_OVERVIEW, me: { points: 0, level: 'Lv.0' } });
setCategories(MOCK_OVERVIEW.categories);
} finally {
setOverviewLoading(false);
}
};
const loadProducts = async () => {
setProductsLoading(true);
try {
const res = await PointsMallAPI.products({
categoryId: categoryId === 'all' ? undefined : categoryId,
q,
sort,
page,
pageSize
});
setProductsRes(res);
} catch {
const filtered = MOCK_PRODUCTS.filter((p) => (categoryId === 'all' ? true : p.categoryId === categoryId)).filter((p) =>
q ? (p.name + p.subtitle).toLowerCase().includes(q.toLowerCase()) : true
);
setProductsRes({
page,
pageSize,
total: filtered.length,
items: filtered.slice((page - 1) * pageSize, page * pageSize)
});
} finally {
setProductsLoading(false);
}
};
useEffect(() => { useEffect(() => {
loadOverview(); const handleResize = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps setIsMobile(isMobileDevice());
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
useEffect(() => { return isMobile ? <PointsMallPageH5 logic={logic} /> : <PointsMallPageWeb logic={logic} />;
loadProducts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryId, q, sort, page, pageSize]);
const banner = overview?.banners?.[0];
const promoEntries = overview?.promoEntries || [];
const products = productsRes?.items || [];
const total = productsRes?.total || 0;
const userPoints = overview?.me?.points || 0;
const totalSpentUSD = overview?.me?.totalSpentUSD;
const handleExchangeClick = async (product: PointsMallProduct) => {
setSelectedProduct(product);
setExchangeQuantity(1);
setConfirmModalVisible(true);
};
const handleConfirmExchange = async () => {
if (!selectedProduct) return;
if (userPoints < selectedProduct.pointsPrice * exchangeQuantity) return;
if (exchangePrepareInFlightRef.current) return;
exchangePrepareInFlightRef.current = true;
setExchangeLoading(true);
try {
const res = await PointsMallAPI.exchangePrepare(selectedProduct.id, exchangeQuantity);
setPendingOrderId(res.orderId);
setPendingExpiresAt(res.expiresAt || new Date(Date.now() + 30 * 60 * 1000).toISOString());
setConfirmModalVisible(false);
setExchangeModalVisible(true);
form.resetFields();
setOverview((prev) => {
if (!prev) return prev;
if (typeof res.remainingPoints !== 'number') return prev;
return { ...prev, me: { ...prev.me, points: res.remainingPoints } };
});
message.success('已冻结积分并预扣库存,请继续填写收件信息');
} catch (e: any) {
const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || '兑换失败,请稍后重试';
message.error(msg);
} finally {
setExchangeLoading(false);
exchangePrepareInFlightRef.current = false;
}
};
const handleExchangeSubmit = async () => {
if (!selectedProduct || !pendingOrderId) return;
try {
await form.validateFields();
setExchangeLoading(true);
const res = await PointsMallAPI.exchangeSubmitShipping(pendingOrderId, form.getFieldsValue());
message.success('兑换成功!我们将尽快为您安排发货');
setExchangeModalVisible(false);
setPendingOrderId(null);
setPendingExpiresAt(null);
setSelectedProduct(null);
setExchangeQuantity(1);
setOverview((prev) => {
if (!prev) return prev;
if (typeof res.remainingPoints !== 'number') return prev;
return { ...prev, me: { ...prev.me, points: res.remainingPoints } };
});
} catch (error: any) {
if (error?.errorFields) return;
const msg = error?.response?.data?.message || error?.response?.data?.error || error?.message || '提交失败,请稍后重试';
message.error(msg);
} finally {
setExchangeLoading(false);
}
};
return (
<div className="page-container" style={{ maxWidth: 1400 }}>
<div className="points-mall-hero">
<div className="points-mall-header">
<div className="points-mall-title-section">
<h1 className="page-title stats-page-title"></h1>
<p className="page-subtitle stats-page-subtitle">使 API 1 = 1000 </p>
</div>
<div className="points-mall-balance-card">
{overviewLoading ? (
<Spin />
) : (
<div className="points-balance-row">
<div>
<div className="points-balance-label"></div>
<div className="points-balance-value">{userPoints.toLocaleString()}</div>
<div className="points-balance-subtext">
${typeof totalSpentUSD === 'number' ? totalSpentUSD.toFixed(2) : '--'}
</div>
</div>
<Tag
bordered={false}
style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}
>
{String(overview?.me?.level || 'Lv.0')}
</Tag>
</div>
)}
</div>
</div>
</div>
<Card className="points-mall-category-card" bodyStyle={{ padding: 0 }}>
<div className="points-mall-category-body">
<div className="points-mall-category-row">
<span className="points-mall-category-label"></span>
{categories.map((c) => (
<Button
key={c.id}
size="small"
type={c.id === categoryId ? 'primary' : 'default'}
style={{ borderRadius: 999 }}
onClick={() => {
setPage(1);
setCategoryId(c.id);
}}
>
{c.name}
</Button>
))}
</div>
</div>
</Card>
<div className="points-mall-banner-section">
<div className="points-mall-banner-card">
<div className="points-mall-banner-header">
<div>
<div className="points-mall-banner-title">{banner?.title || '本期活动'}</div>
<div className="points-mall-banner-subtitle">{banner?.subtitle || 'Up to 25% Off'}</div>
</div>
<Button type="primary" style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button>
</div>
<div className="points-mall-banner-footer">banner </div>
</div>
<div className="points-mall-promo-grid">
{promoEntries.slice(0, 2).map((p) => (
<div key={p.id} className="points-mall-promo-card">
<div style={{ minWidth: 0 }}>
<div className="points-mall-promo-title">{p.title}</div>
<div className="points-mall-promo-subtitle">{p.subtitle}</div>
</div>
<Button size="small" className="points-mall-exchange-btn">
</Button>
</div>
))}
{promoEntries.length < 2 && <div className="points-mall-promo-empty"></div>}
</div>
</div>
<Card className="stats-page-chart-card">
<div className="points-mall-filters-row">
<Space size={10} wrap className="points-mall-filters-left">
<Input
value={q}
onChange={(e) => {
setPage(1);
setQ(e.target.value);
}}
prefix={<SearchOutlined />}
placeholder="搜索商品"
allowClear
className="points-mall-search-input"
/>
<Select
value={sort}
className="points-mall-filter-select"
onChange={(v: SortKey) => {
setPage(1);
setSort(v);
}}
options={[
{ value: 'popular', label: '热度优先' },
{ value: 'newest', label: '最新上架' },
{ value: 'price_asc', label: '积分从低到高' },
{ value: 'price_desc', label: '积分从高到低' }
]}
/>
<Select
value={pageSize}
className="points-mall-filter-select-small"
onChange={(v) => {
setPage(1);
setPageSize(v);
}}
options={[
{ value: 12, label: '每页 12' },
{ value: 24, label: '每页 24' },
{ value: 48, label: '每页 48' }
]}
/>
</Space>
<div className="points-mall-total-text"> {total.toLocaleString()} </div>
</div>
{productsLoading ? (
<Spin style={{ marginTop: 40, display: 'block' }} />
) : products.length === 0 ? (
<Empty description="暂无商品" style={{ marginTop: 60 }} />
) : (
<div className="points-mall-products-grid">
{products.map((p) => (
<div key={p.id} className="points-mall-product-card">
<div className="points-mall-product-cover" />
<div className="points-mall-product-body">
<div className="points-mall-product-header">
<div className="points-mall-product-info">
<div className="points-mall-product-name">{p.name}</div>
<div className="points-mall-product-desc">{p.subtitle}</div>
</div>
{p.tags?.length ? (
<Tag bordered={false} className="points-mall-product-tag">
{p.tags[0]}
</Tag>
) : null}
</div>
<div className="points-mall-product-price-row">
<div>
<span className="points-mall-product-price">{Number(p.pointsPrice).toLocaleString()}</span>
<span className="points-mall-product-price-label"></span>
</div>
<Button
type="primary"
className="points-mall-exchange-btn points-mall-product-exchange-btn"
disabled={userPoints < p.pointsPrice || exchangeLoading}
onClick={() => handleExchangeClick(p)}
>
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
</Button>
</div>
<div className="points-mall-product-footer">
<span> {p.stock}</span>
<span> {p.sold}</span>
</div>
</div>
</div>
))}
</div>
)}
{!!productsRes && (
<div className="points-mall-pagination">
<Space size={10}>
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn">
</Button>
<Tag bordered={false} className="points-mall-pagination-tag">
{page}
</Tag>
<Button disabled={page * pageSize >= total} onClick={() => setPage((v) => v + 1)} className="points-mall-exchange-btn">
</Button>
</Space>
</div>
)}
</Card>
<ExchangeModal
open={exchangeModalVisible}
product={selectedProduct}
quantity={exchangeQuantity}
expiresAt={pendingExpiresAt}
loading={exchangeLoading}
form={form}
onSubmit={handleExchangeSubmit}
onCancel={() => {
setExchangeModalVisible(false);
setPendingOrderId(null);
setPendingExpiresAt(null);
setSelectedProduct(null);
setExchangeQuantity(1);
}}
/>
<ConfirmExchangeModal
open={confirmModalVisible}
product={selectedProduct}
userPoints={userPoints}
quantity={exchangeQuantity}
loading={exchangeLoading}
onQuantityChange={(v) => setExchangeQuantity(Math.max(1, Math.floor(v || 1)))}
onConfirm={handleConfirmExchange}
onCancel={() => {
if (exchangeLoading) return;
setConfirmModalVisible(false);
setSelectedProduct(null);
setExchangeQuantity(1);
}}
/>
</div>
);
} }

View File

@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from 'react';
import { message } from 'antd';
import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../../api';
import { MOCK_OVERVIEW, MOCK_PRODUCTS } from './mocks';
import type { SortKey } from './types';
export function usePointsMallPageLogic() {
const [overviewLoading, setOverviewLoading] = useState(false);
const [productsLoading, setProductsLoading] = useState(false);
const [overview, setOverview] = useState<PointsMallOverview | null>(null);
const [categories, setCategories] = useState<PointsMallCategory[]>([]);
const [categoryId, setCategoryId] = useState<string>('all');
const [q, setQ] = useState('');
const [sort, setSort] = useState<SortKey>('popular');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(24);
const [productsRes, setProductsRes] = useState<PointsMallProductsResponse | null>(null);
const [exchangeModalVisible, setExchangeModalVisible] = useState(false);
const [confirmModalVisible, setConfirmModalVisible] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<PointsMallProduct | null>(null);
const [exchangeQuantity, setExchangeQuantity] = useState(1);
const [pendingOrderId, setPendingOrderId] = useState<string | null>(null);
const [pendingExpiresAt, setPendingExpiresAt] = useState<string | null>(null);
const [exchangeLoading, setExchangeLoading] = useState(false);
const exchangePrepareInFlightRef = useRef(false);
const loadOverview = async () => {
setOverviewLoading(true);
try {
const [meRes, categoriesRes, announcementsRes, bannersRes, promoEntriesRes] = await Promise.allSettled([
PointsMallAPI.me(),
PointsMallAPI.categories(),
PointsMallAPI.announcements(),
PointsMallAPI.banners(),
PointsMallAPI.promoEntries(),
]);
const me = meRes.status === 'fulfilled' ? meRes.value : { points: 0, level: 'Lv.0' };
const cats = categoriesRes.status === 'fulfilled' ? categoriesRes.value : MOCK_OVERVIEW.categories;
const announcements = announcementsRes.status === 'fulfilled' ? announcementsRes.value : MOCK_OVERVIEW.announcements;
const banners = bannersRes.status === 'fulfilled' ? bannersRes.value : MOCK_OVERVIEW.banners;
const promoEntries = promoEntriesRes.status === 'fulfilled' ? promoEntriesRes.value : MOCK_OVERVIEW.promoEntries;
setOverview({ me, categories: cats, announcements, banners, promoEntries });
setCategories(cats);
if (!cats?.some((c) => c.id === categoryId) && cats?.[0]?.id) {
setCategoryId(cats[0].id);
}
if (meRes.status === 'rejected') {
message.error('获取积分信息失败,请稍后重试');
}
} catch {
message.error('获取积分信息失败,请稍后重试');
setOverview({ ...MOCK_OVERVIEW, me: { points: 0, level: 'Lv.0' } });
setCategories(MOCK_OVERVIEW.categories);
} finally {
setOverviewLoading(false);
}
};
const loadProducts = async () => {
setProductsLoading(true);
try {
const res = await PointsMallAPI.products({
categoryId: categoryId === 'all' ? undefined : categoryId,
q,
sort,
page,
pageSize,
});
setProductsRes(res);
} catch {
const filtered = MOCK_PRODUCTS.filter((p) => (categoryId === 'all' ? true : p.categoryId === categoryId)).filter((p) =>
q ? (p.name + p.subtitle).toLowerCase().includes(q.toLowerCase()) : true
);
setProductsRes({
page,
pageSize,
total: filtered.length,
items: filtered.slice((page - 1) * pageSize, page * pageSize),
});
} finally {
setProductsLoading(false);
}
};
useEffect(() => {
loadOverview();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
loadProducts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryId, q, sort, page, pageSize]);
const banner = overview?.banners?.[0];
const promoEntries = overview?.promoEntries || [];
const products = productsRes?.items || [];
const total = productsRes?.total || 0;
const userPoints = overview?.me?.points || 0;
const totalSpentUSD = overview?.me?.totalSpentUSD;
const handleExchangeClick = async (product: PointsMallProduct) => {
setSelectedProduct(product);
setExchangeQuantity(1);
setConfirmModalVisible(true);
};
const handleConfirmExchange = async () => {
if (!selectedProduct) return;
if (userPoints < selectedProduct.pointsPrice * exchangeQuantity) return;
if (exchangePrepareInFlightRef.current) return;
exchangePrepareInFlightRef.current = true;
setExchangeLoading(true);
try {
const res = await PointsMallAPI.exchangePrepare(selectedProduct.id, exchangeQuantity);
setPendingOrderId(res.orderId);
setPendingExpiresAt(res.expiresAt || new Date(Date.now() + 30 * 60 * 1000).toISOString());
setConfirmModalVisible(false);
setExchangeModalVisible(true);
if (typeof res.remainingPoints === 'number') {
setOverview((prev) => {
if (!prev) return prev;
return { ...prev, me: { ...prev.me, points: res.remainingPoints } };
});
}
message.success('已冻结积分并预扣库存,请继续填写收件信息');
} catch (e: any) {
const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || '兑换失败,请稍后重试';
message.error(msg);
} finally {
setExchangeLoading(false);
exchangePrepareInFlightRef.current = false;
}
};
const handleExchangeSubmit = async () => {
if (!selectedProduct || !pendingOrderId) return;
};
return {
overview,
overviewLoading,
categories,
categoryId,
q,
sort,
page,
pageSize,
productsLoading,
products,
total,
userPoints,
totalSpentUSD,
banner,
promoEntries,
exchangeModalVisible,
confirmModalVisible,
selectedProduct,
exchangeQuantity,
pendingOrderId,
pendingExpiresAt,
exchangeLoading,
setCategoryId,
setQ,
setSort,
setPage,
setPageSize,
handleExchangeClick,
handleConfirmExchange,
setExchangeModalVisible,
setConfirmModalVisible,
setPendingOrderId,
setPendingExpiresAt,
setSelectedProduct,
setExchangeQuantity,
setOverview,
handleExchangeSubmit,
};
}
export type PointsMallPageLogicOutput = ReturnType<typeof usePointsMallPageLogic>;

View File

@ -0,0 +1,276 @@
import { Button, Card, Empty, Input, Select, Space, Spin, Tag } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import type { PointsMallProduct } from '../../../api';
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
import ExchangeModal from '../components/ExchangeModal';
import ConfirmExchangeModal from '../components/ConfirmExchangeModal';
interface Props {
logic: PointsMallPageLogicOutput;
}
export default function PointsMallPageH5({ logic }: Props) {
const {
overview,
overviewLoading,
categories,
categoryId,
q,
sort,
page,
pageSize,
productsLoading,
products,
total,
userPoints,
totalSpentUSD,
banner,
promoEntries,
exchangeModalVisible,
confirmModalVisible,
selectedProduct,
exchangeQuantity,
pendingExpiresAt,
exchangeLoading,
setCategoryId,
setQ,
setSort,
setPage,
setPageSize,
handleExchangeClick,
setExchangeModalVisible,
setConfirmModalVisible,
} = logic;
return (
<div className="page-container h5-page-container" style={{ paddingLeft: 8, paddingRight: 8 }}>
<div className="points-mall-hero h5-points-mall-hero" style={{ padding: '16px 12px' }}>
<div className="points-mall-header h5-points-mall-header" style={{ flexDirection: 'column', gap: 12 }}>
<div className="points-mall-title-section">
<h1 className="page-title stats-page-title h5-page-title" style={{ fontSize: 24, marginBottom: 6 }}></h1>
<p className="page-subtitle stats-page-subtitle h5-page-subtitle" style={{ fontSize: 13, lineHeight: 1.5 }}>
使<br /> API 1 = 1000
</p>
</div>
<div className="points-mall-balance-card h5-points-mall-balance-card" style={{ width: '100%', padding: 12 }}>
{overviewLoading ? (
<Spin />
) : (
<div className="points-balance-row h5-points-balance-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<div style={{ width: '100%' }}>
<div className="points-balance-label" style={{ fontSize: 12 }}></div>
<div className="points-balance-value" style={{ fontSize: 28 }}>{userPoints.toLocaleString()}</div>
<div className="points-balance-subtext" style={{ fontSize: 12 }}>
${typeof totalSpentUSD === 'number' ? totalSpentUSD.toFixed(2) : '--'}
</div>
</div>
<Tag
bordered={false}
style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', fontSize: 12 }}
>
{String(overview?.me?.level || 'Lv.0')}
</Tag>
</div>
)}
</div>
</div>
</div>
<Card className="points-mall-category-card h5-points-mall-category-card" bodyStyle={{ padding: 0 }}>
<div className="points-mall-category-body">
<div className="points-mall-category-row h5-points-mall-category-row" style={{ flexWrap: 'wrap', gap: 6 }}>
<span className="points-mall-category-label" style={{ width: '100%', marginBottom: 8 }}></span>
{categories.map((c) => (
<Button
key={c.id}
size="small"
type={c.id === categoryId ? 'primary' : 'default'}
style={{ borderRadius: 999, fontSize: 12 }}
onClick={() => {
setPage(1);
setCategoryId(c.id);
}}
>
{c.name}
</Button>
))}
</div>
</div>
</Card>
<div className="points-mall-banner-section h5-points-mall-banner-section" style={{ flexDirection: 'column', gap: 12 }}>
<div className="points-mall-banner-card h5-points-mall-banner-card" style={{ width: '100%' }}>
<div className="points-mall-banner-header" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<div>
<div className="points-mall-banner-title" style={{ fontSize: 16 }}>{banner?.title || '本期活动'}</div>
<div className="points-mall-banner-subtitle" style={{ fontSize: 12 }}>{banner?.subtitle || 'Up to 25% Off'}</div>
</div>
<Button type="primary" style={{ borderRadius: 10, height: 36, fontWeight: 600, width: '100%' }}>
</Button>
</div>
<div className="points-mall-banner-footer">banner </div>
</div>
<div className="points-mall-promo-grid h5-points-mall-promo-grid" style={{ gridTemplateColumns: '1fr', gap: 8 }}>
{promoEntries.slice(0, 2).map((p) => (
<div key={p.id} className="points-mall-promo-card" style={{ padding: 12 }}>
<div style={{ minWidth: 0 }}>
<div className="points-mall-promo-title" style={{ fontSize: 14 }}>{p.title}</div>
<div className="points-mall-promo-subtitle" style={{ fontSize: 12 }}>{p.subtitle}</div>
</div>
<Button size="small" className="points-mall-exchange-btn" style={{ marginTop: 8 }}>
</Button>
</div>
))}
{promoEntries.length < 2 && <div className="points-mall-promo-empty"></div>}
</div>
</div>
<Card className="stats-page-chart-card h5-stats-page-chart-card">
<div className="points-mall-filters-row h5-points-mall-filters-row" style={{ flexDirection: 'column', gap: 10 }}>
<div style={{ width: '100%' }}>
<Input
value={q}
onChange={(e) => {
setPage(1);
setQ(e.target.value);
}}
prefix={<SearchOutlined />}
placeholder="搜索商品"
allowClear
className="points-mall-search-input"
style={{ height: 36 }}
/>
</div>
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Select
value={sort}
className="points-mall-filter-select"
style={{ flex: 1 }}
onChange={(v) => {
setPage(1);
setSort(v);
}}
options={[
{ value: 'popular', label: '热度优先' },
{ value: 'newest', label: '最新上架' },
{ value: 'price_asc', label: '积分从低到高' },
{ value: 'price_desc', label: '积分从高到低' },
]}
/>
<Select
value={pageSize}
className="points-mall-filter-select-small"
style={{ width: 80 }}
onChange={(v) => {
setPage(1);
setPageSize(v);
}}
options={[
{ value: 12, label: '每页 12' },
{ value: 24, label: '每页 24' },
]}
/>
</div>
<div className="points-mall-total-text" style={{ textAlign: 'left' }}> {total.toLocaleString()} </div>
</div>
{productsLoading ? (
<Spin style={{ marginTop: 30, display: 'block' }} />
) : products.length === 0 ? (
<Empty description="暂无商品" style={{ marginTop: 40 }} />
) : (
<div className="points-mall-products-grid h5-points-mall-products-grid" style={{ gridTemplateColumns: '1fr', gap: 12 }}>
{products.map((p: PointsMallProduct) => (
<div key={p.id} className="points-mall-product-card h5-points-mall-product-card" style={{ padding: 12 }}>
<div className="points-mall-product-cover" />
<div className="points-mall-product-body">
<div className="points-mall-product-header">
<div className="points-mall-product-info">
<div className="points-mall-product-name" style={{ fontSize: 15 }}>{p.name}</div>
<div className="points-mall-product-desc" style={{ fontSize: 12 }}>{p.subtitle}</div>
</div>
{p.tags?.length ? (
<Tag bordered={false} className="points-mall-product-tag" style={{ fontSize: 11 }}>
{p.tags[0]}
</Tag>
) : null}
</div>
<div className="points-mall-product-price-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<div>
<span className="points-mall-product-price" style={{ fontSize: 20 }}>{Number(p.pointsPrice).toLocaleString()}</span>
<span className="points-mall-product-price-label"></span>
</div>
<Button
type="primary"
className="points-mall-exchange-btn points-mall-product-exchange-btn"
disabled={userPoints < p.pointsPrice || exchangeLoading}
onClick={() => handleExchangeClick(p)}
style={{ width: '100%' }}
>
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
</Button>
</div>
<div className="points-mall-product-footer" style={{ fontSize: 12 }}>
<span> {p.stock}</span>
<span> {p.sold}</span>
</div>
</div>
</div>
))}
</div>
)}
{!!total && (
<div className="points-mall-pagination h5-points-mall-pagination" style={{ marginTop: 16 }}>
<Space size={8}>
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn" size="small">
</Button>
<Tag bordered={false} className="points-mall-pagination-tag">
{page}
</Tag>
<Button disabled={page * pageSize >= total} onClick={() => setPage((v) => v + 1)} className="points-mall-exchange-btn" size="small">
</Button>
</Space>
</div>
)}
</Card>
<ExchangeModal
open={exchangeModalVisible}
product={selectedProduct}
quantity={exchangeQuantity}
expiresAt={pendingExpiresAt}
loading={exchangeLoading}
onSubmit={logic.handleExchangeSubmit}
onCancel={() => {
setExchangeModalVisible(false);
logic.setPendingOrderId(null);
logic.setPendingExpiresAt(null);
logic.setSelectedProduct(null);
logic.setExchangeQuantity(1);
}}
/>
<ConfirmExchangeModal
open={confirmModalVisible}
product={selectedProduct}
userPoints={userPoints}
quantity={exchangeQuantity}
loading={exchangeLoading}
onQuantityChange={(v) => logic.setExchangeQuantity(Math.max(1, Math.floor(v || 1)))}
onConfirm={logic.handleConfirmExchange}
onCancel={() => {
if (exchangeLoading) return;
setConfirmModalVisible(false);
logic.setSelectedProduct(null);
logic.setExchangeQuantity(1);
}}
/>
</div>
);
}

View File

@ -0,0 +1,269 @@
import { Button, Card, Empty, Input, Select, Space, Spin, Tag } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import type { PointsMallProduct } from '../../../api';
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
import ExchangeModal from '../components/ExchangeModal';
import ConfirmExchangeModal from '../components/ConfirmExchangeModal';
interface Props {
logic: PointsMallPageLogicOutput;
}
export default function PointsMallPageWeb({ logic }: Props) {
const {
overview,
overviewLoading,
categories,
categoryId,
q,
sort,
page,
pageSize,
productsLoading,
products,
total,
userPoints,
totalSpentUSD,
banner,
promoEntries,
exchangeModalVisible,
confirmModalVisible,
selectedProduct,
exchangeQuantity,
pendingExpiresAt,
exchangeLoading,
setCategoryId,
setQ,
setSort,
setPage,
setPageSize,
handleExchangeClick,
setExchangeModalVisible,
setConfirmModalVisible,
} = logic;
return (
<div className="page-container" style={{ maxWidth: 1400 }}>
<div className="points-mall-hero">
<div className="points-mall-header">
<div className="points-mall-title-section">
<h1 className="page-title stats-page-title"></h1>
<p className="page-subtitle stats-page-subtitle">使 API 1 = 1000 </p>
</div>
<div className="points-mall-balance-card">
{overviewLoading ? (
<Spin />
) : (
<div className="points-balance-row">
<div>
<div className="points-balance-label"></div>
<div className="points-balance-value">{userPoints.toLocaleString()}</div>
<div className="points-balance-subtext">
${typeof totalSpentUSD === 'number' ? totalSpentUSD.toFixed(2) : '--'}
</div>
</div>
<Tag
bordered={false}
style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}
>
{String(overview?.me?.level || 'Lv.0')}
</Tag>
</div>
)}
</div>
</div>
</div>
<Card className="points-mall-category-card" bodyStyle={{ padding: 0 }}>
<div className="points-mall-category-body">
<div className="points-mall-category-row">
<span className="points-mall-category-label"></span>
{categories.map((c) => (
<Button
key={c.id}
size="small"
type={c.id === categoryId ? 'primary' : 'default'}
style={{ borderRadius: 999 }}
onClick={() => {
setPage(1);
setCategoryId(c.id);
}}
>
{c.name}
</Button>
))}
</div>
</div>
</Card>
<div className="points-mall-banner-section">
<div className="points-mall-banner-card">
<div className="points-mall-banner-header">
<div>
<div className="points-mall-banner-title">{banner?.title || '本期活动'}</div>
<div className="points-mall-banner-subtitle">{banner?.subtitle || 'Up to 25% Off'}</div>
</div>
<Button type="primary" style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button>
</div>
<div className="points-mall-banner-footer">banner </div>
</div>
<div className="points-mall-promo-grid">
{promoEntries.slice(0, 2).map((p) => (
<div key={p.id} className="points-mall-promo-card">
<div style={{ minWidth: 0 }}>
<div className="points-mall-promo-title">{p.title}</div>
<div className="points-mall-promo-subtitle">{p.subtitle}</div>
</div>
<Button size="small" className="points-mall-exchange-btn">
</Button>
</div>
))}
{promoEntries.length < 2 && <div className="points-mall-promo-empty"></div>}
</div>
</div>
<Card className="stats-page-chart-card">
<div className="points-mall-filters-row">
<Space size={10} wrap className="points-mall-filters-left">
<Input
value={q}
onChange={(e) => {
setPage(1);
setQ(e.target.value);
}}
prefix={<SearchOutlined />}
placeholder="搜索商品"
allowClear
className="points-mall-search-input"
/>
<Select
value={sort}
className="points-mall-filter-select"
onChange={(v) => {
setPage(1);
setSort(v);
}}
options={[
{ value: 'popular', label: '热度优先' },
{ value: 'newest', label: '最新上架' },
{ value: 'price_asc', label: '积分从低到高' },
{ value: 'price_desc', label: '积分从高到低' },
]}
/>
<Select
value={pageSize}
className="points-mall-filter-select-small"
onChange={(v) => {
setPage(1);
setPageSize(v);
}}
options={[
{ value: 12, label: '每页 12' },
{ value: 24, label: '每页 24' },
{ value: 48, label: '每页 48' },
]}
/>
</Space>
<div className="points-mall-total-text"> {total.toLocaleString()} </div>
</div>
{productsLoading ? (
<Spin style={{ marginTop: 40, display: 'block' }} />
) : products.length === 0 ? (
<Empty description="暂无商品" style={{ marginTop: 60 }} />
) : (
<div className="points-mall-products-grid">
{products.map((p: PointsMallProduct) => (
<div key={p.id} className="points-mall-product-card">
<div className="points-mall-product-cover" />
<div className="points-mall-product-body">
<div className="points-mall-product-header">
<div className="points-mall-product-info">
<div className="points-mall-product-name">{p.name}</div>
<div className="points-mall-product-desc">{p.subtitle}</div>
</div>
{p.tags?.length ? (
<Tag bordered={false} className="points-mall-product-tag">
{p.tags[0]}
</Tag>
) : null}
</div>
<div className="points-mall-product-price-row">
<div>
<span className="points-mall-product-price">{Number(p.pointsPrice).toLocaleString()}</span>
<span className="points-mall-product-price-label"></span>
</div>
<Button
type="primary"
className="points-mall-exchange-btn points-mall-product-exchange-btn"
disabled={userPoints < p.pointsPrice || exchangeLoading}
onClick={() => handleExchangeClick(p)}
>
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
</Button>
</div>
<div className="points-mall-product-footer">
<span> {p.stock}</span>
<span> {p.sold}</span>
</div>
</div>
</div>
))}
</div>
)}
{!!total && (
<div className="points-mall-pagination">
<Space size={10}>
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn">
</Button>
<Tag bordered={false} className="points-mall-pagination-tag">
{page}
</Tag>
<Button disabled={page * pageSize >= total} onClick={() => setPage((v) => v + 1)} className="points-mall-exchange-btn">
</Button>
</Space>
</div>
)}
</Card>
<ExchangeModal
open={exchangeModalVisible}
product={selectedProduct}
quantity={exchangeQuantity}
expiresAt={pendingExpiresAt}
loading={exchangeLoading}
onSubmit={logic.handleExchangeSubmit}
onCancel={() => {
setExchangeModalVisible(false);
logic.setPendingOrderId(null);
logic.setPendingExpiresAt(null);
logic.setSelectedProduct(null);
logic.setExchangeQuantity(1);
}}
/>
<ConfirmExchangeModal
open={confirmModalVisible}
product={selectedProduct}
userPoints={userPoints}
quantity={exchangeQuantity}
loading={exchangeLoading}
onQuantityChange={(v) => logic.setExchangeQuantity(Math.max(1, Math.floor(v || 1)))}
onConfirm={logic.handleConfirmExchange}
onCancel={() => {
if (exchangeLoading) return;
setConfirmModalVisible(false);
logic.setSelectedProduct(null);
logic.setExchangeQuantity(1);
}}
/>
</div>
);
}

View File

@ -1,400 +1,24 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import { useTeamsPageLogic } from './TeamsPage/TeamsPageLogic';
CopyOutlined, import TeamsPageWeb from './TeamsPage/components/TeamsPageWeb';
DeleteOutlined, import TeamsPageH5 from './TeamsPage/components/TeamsPageH5';
MailOutlined,
PlusOutlined,
TeamOutlined,
UserOutlined
} from '@ant-design/icons';
import { Card, Button, List, Tag, Space, Popconfirm, App as AntApp, Modal, Form, Input, Empty } from 'antd';
import { AuthAPI, Team, TeamAPI } from '../api';
export default function TeamsPage() {
const { message } = AntApp.useApp();
const [list, setList] = useState<Team[]>([]);
const [active, setActive] = useState<Team | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [lastInviteCode, setLastInviteCode] = useState<string | null>(null);
const load = async () => {
const l = await TeamAPI.list();
setList(l);
if (l.length && !active) setActive(await TeamAPI.detail(l[0].id));
};
useEffect(() => {
load();
}, []);
const handleCreate = async (v: any) => {
const t = await TeamAPI.create(v.name);
setCreateOpen(false);
message.success('已创建');
await load();
setActive(await TeamAPI.detail(t.id));
};
const handleInvite = async (v: any) => {
if (!active) return;
const inv = await AuthAPI.createInvite({
teamId: active.id,
email: v.email || undefined,
ttlHours: Number(v.ttlHours) || 168
});
setLastInviteCode(inv.code);
};
return (
<div className="feature-cover-container">
<div className="page-container" style={{ maxWidth: 1080 }}>
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 20
}}
>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16
}}
>
<TeamOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}></h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
</div>
</div>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}>
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}> const isMobileDevice = () => {
{[ if (typeof window === 'undefined') return false;
{ label: '团队数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' }, return window.innerWidth < 768;
{ label: '当前成员数', value: active?.members?.length ?? 0, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' }, };
{ label: '共享智能体', value: active?.agentCount ?? 0, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' }
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)'
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600
}}
>
</span>
</div>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 22, alignItems: 'stretch' }}>
<div
style={{
width: 260,
flexShrink: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
border: '1px solid var(--color-border)',
borderRadius: 22,
padding: 14,
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)'
}}
>
<div style={{ padding: '8px 10px 14px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}></div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}></div>
</div>
{list.length === 0 ? (
<Empty description="还没有团队" />
) : (
<List
dataSource={list}
renderItem={(item) => (
<div
className={`nav-item ${active?.id === item.id ? 'active' : ''}`}
onClick={async () => setActive(await TeamAPI.detail(item.id))}
style={{
padding: '10px 12px',
borderRadius: 14,
cursor: 'pointer',
marginBottom: 6,
background: active?.id === item.id ? 'rgba(8, 145, 178, 0.10)' : 'transparent',
color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-secondary)',
fontWeight: active?.id === item.id ? 600 : 500,
border: active?.id === item.id ? '1px solid rgba(8, 145, 178, 0.16)' : '1px solid transparent'
}}
>
<div style={{ fontSize: 14, marginBottom: 4 }}>{item.name}</div>
<div style={{ fontSize: 12, color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-tertiary)' }}>
{item.agentCount ?? 0}
</div>
</div>
)}
/>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{active ? (
<Card
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
bodyStyle={{ padding: 22 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 20 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)' }}>{active.name}</span>
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0 }}>{active.myRole}</Tag>
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>{active.agentCount ?? 0} </Tag>
</div>
<div style={{ fontSize: 13.5, color: 'var(--color-text-secondary)' }}>
</div>
</div>
<Space>
{(active.myRole === 'owner' || active.myRole === 'admin') && (
<Button icon={<MailOutlined />} onClick={() => setInviteOpen(true)} style={{ borderRadius: 12 }}>
</Button>
)}
{active.myRole === 'owner' && (
<Popconfirm
title="确定删除该团队?团队内的智能体会变成 owner 私有"
onConfirm={async () => {
await TeamAPI.remove(active.id);
message.success('已删除');
setActive(null);
load();
}}
>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 12 }}>
</Button>
</Popconfirm>
)}
</Space>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 20 }}> export default function TeamsPage() {
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}> const logic = useTeamsPageLogic();
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div> const [isMobile, setIsMobile] = useState(isMobileDevice());
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{active.members?.length || 0}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{active.agentCount ?? 0}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)', textTransform: 'capitalize' }}>{active.myRole}</div>
</div>
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', marginBottom: 14 }}> useEffect(() => {
({active.members?.length || 0}) const handleResize = () => {
</div> setIsMobile(isMobileDevice());
<List };
dataSource={active.members || []} window.addEventListener('resize', handleResize);
renderItem={(m) => ( return () => window.removeEventListener('resize', handleResize);
<List.Item }, []);
style={{ padding: '14px 0' }}
actions={ return isMobile ? <TeamsPageH5 logic={logic} /> : <TeamsPageWeb logic={logic} />;
(active.myRole === 'owner' || active.myRole === 'admin') && m.role !== 'owner' }
? [
<Popconfirm
key="kick"
title="移除该成员?"
onConfirm={async () => {
await TeamAPI.removeMember(active.id, m.id);
message.success('已移除');
setActive(await TeamAPI.detail(active.id));
}}
>
<Button size="small" danger style={{ borderRadius: 10 }}>
</Button>
</Popconfirm>
]
: []
}
>
<List.Item.Meta
avatar={
<div
style={{
width: 42,
height: 42,
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.10)',
color: 'var(--color-brand)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<UserOutlined />
</div>
}
title={
<Space>
<span style={{ fontWeight: 600 }}>{m.name}</span>
<Tag
bordered={false}
style={{
background:
m.role === 'owner'
? 'var(--color-warning-soft)'
: m.role === 'admin'
? 'var(--color-info-soft)'
: 'var(--color-surface-2)',
color:
m.role === 'owner'
? 'var(--color-warning)'
: m.role === 'admin'
? 'var(--color-info)'
: 'var(--color-text-secondary)',
borderRadius: 999,
margin: 0
}}
>
{m.role}
</Tag>
</Space>
}
description={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span>{m.email}</span>
<span style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{new Date(m.joinedAt).toLocaleDateString('zh-CN')}
</span>
</div>
}
/>
</List.Item>
)}
/>
</Card>
) : (
<Empty description="选择或创建一个团队" />
)}
</div>
</div>
<Modal
open={createOpen}
title="新建团队"
onCancel={() => setCreateOpen(false)}
footer={null}
destroyOnHidden
>
<Form layout="vertical" onFinish={handleCreate}>
<Form.Item name="name" label="团队名称" rules={[{ required: true }]}>
<Input placeholder="如AI 实验小组" autoFocus />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
<Modal
open={inviteOpen}
title={`📨 邀请加入 ${active?.name}`}
onCancel={() => {
setInviteOpen(false);
setLastInviteCode(null);
}}
footer={null}
destroyOnHidden
>
{lastInviteCode ? (
<div>
<div style={{ marginBottom: 12 }}></div>
<Input.TextArea
value={lastInviteCode}
readOnly
autoSize
style={{ fontFamily: 'monospace', fontSize: 16 }}
/>
<Button
type="default"
icon={<CopyOutlined />}
style={{ marginTop: 12, borderRadius: 10 }}
onClick={() => {
navigator.clipboard?.writeText(lastInviteCode).then(() => message.success('邀请码已复制'));
}}
>
</Button>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 12, marginTop: 8 }}>
</div>
</div>
) : (
<Form layout="vertical" onFinish={handleInvite}>
<Form.Item name="email" label="限定邮箱(可选)">
<Input placeholder="只允许该邮箱使用此邀请码" />
</Form.Item>
<Form.Item name="ttlHours" label="有效期(小时)" initialValue={168}>
<Input type="number" placeholder="168 = 7 天" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
)}
</Modal>
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { message } from 'antd';
import { AuthAPI, Team, TeamAPI } from '../../api';
export function useTeamsPageLogic() {
const [list, setList] = useState<Team[]>([]);
const [active, setActive] = useState<Team | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [lastInviteCode, setLastInviteCode] = useState<string | null>(null);
const load = async () => {
const l = await TeamAPI.list();
setList(l);
if (l.length && !active) setActive(await TeamAPI.detail(l[0].id));
};
useEffect(() => {
load();
}, []);
const handleCreate = async (v: any) => {
const t = await TeamAPI.create(v.name);
setCreateOpen(false);
message.success('已创建');
await load();
setActive(await TeamAPI.detail(t.id));
};
const handleInvite = async (v: any) => {
if (!active) return;
const inv = await AuthAPI.createInvite({
teamId: active.id,
email: v.email || undefined,
ttlHours: Number(v.ttlHours) || 168,
});
setLastInviteCode(inv.code);
};
const handleDelete = async (id: string) => {
await TeamAPI.remove(id);
message.success('已删除');
await load();
setActive(null);
};
const handleRemoveMember = async (id: string) => {
if (!active) return;
await TeamAPI.removeMember(active.id, id);
message.success('已移除');
setActive(await TeamAPI.detail(active.id));
};
return {
list,
active,
createOpen,
inviteOpen,
lastInviteCode,
load,
handleCreate,
handleInvite,
handleDelete,
handleRemoveMember,
setCreateOpen,
setInviteOpen,
};
}
export type TeamsPageLogicOutput = ReturnType<typeof useTeamsPageLogic>;

View File

@ -0,0 +1,374 @@
import {
CopyOutlined,
DeleteOutlined,
MailOutlined,
PlusOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
import { Card, Button, List, Tag, Space, Popconfirm, App as AntApp, Modal, Form, Input, Empty } from 'antd';
import type { Team } from '../../../api';
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
interface Props {
logic: TeamsPageLogicOutput;
}
export default function TeamsPageH5({ logic }: Props) {
const { message } = AntApp.useApp();
const {
list,
active,
handleDelete,
handleRemoveMember,
setCreateOpen,
} = logic;
return (
<div className="feature-cover-container">
<div className="page-container h5-page-container" style={{ padding: '0 8px' }}>
<div
style={{
borderRadius: 16,
padding: '20px 16px 18px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
marginBottom: 16,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
flexWrap: 'wrap',
marginBottom: 16,
}}
>
<div style={{ width: '100%' }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 11,
fontWeight: 600,
marginBottom: 12,
}}
>
<TeamOutlined style={{ color: 'var(--color-brand)', fontSize: 12 }} />
</div>
<h1 className="page-title h5-page-title" style={{ marginBottom: 8, fontSize: 22 }}></h1>
<div className="page-subtitle h5-page-subtitle" style={{ marginTop: 0, fontSize: 13, lineHeight: 1.6 }}>
</div>
</div>
<Button
type="primary"
size="middle"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
style={{ borderRadius: 10, height: 40, padding: '0 14px', fontWeight: 600, width: '100%' }}
>
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gap: 10 }}>
{[
{ label: '团队数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '当前成员数', value: active?.members?.length ?? 0, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
{ label: '共享智能体', value: active?.agentCount ?? 0, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 12,
padding: '12px 14px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)',
}}
>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '3px 6px',
background: item.tone,
color: item.color,
fontSize: 10,
fontWeight: 600,
}}
>
</span>
</div>
</div>
))}
</div>
</div>
{list.length > 0 && (
<div
style={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: 12,
marginBottom: 16,
}}
>
<div style={{ padding: '4px 6px 10px' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}></div>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}></div>
</div>
{list.map((item) => (
<div
className={`nav-item ${active?.id === item.id ? 'active' : ''}`}
onClick={async () => {
logic.setActive(await TeamAPI.detail(item.id));
}}
style={{
padding: '10px 12px',
borderRadius: 10,
cursor: 'pointer',
marginBottom: 6,
background: active?.id === item.id ? 'rgba(8, 145, 178, 0.10)' : 'transparent',
color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-secondary)',
fontWeight: active?.id === item.id ? 600 : 500,
border: active?.id === item.id ? '1px solid rgba(8, 145, 178, 0.16)' : '1px solid transparent',
}}
>
<div style={{ fontSize: 13, marginBottom: 3 }}>{item.name}</div>
<div style={{ fontSize: 11, color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-tertiary)' }}>
{item.agentCount ?? 0}
</div>
</div>
))}
</div>
)}
{active ? (
<Card
style={{ borderRadius: 16, boxShadow: '0 6px 16px rgba(15, 23, 42, 0.045)' }}
bodyStyle={{ padding: 16 }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 4 }}>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>{active.name}</span>
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, fontSize: 11 }}>{active.myRole}</Tag>
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0, fontSize: 11 }}>{active.agentCount ?? 0} </Tag>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
</div>
</div>
<Space wrap>
{(active.myRole === 'owner' || active.myRole === 'admin') && (
<Button icon={<MailOutlined />} size="small" onClick={() => logic.setInviteOpen(true)} style={{ borderRadius: 8 }}>
</Button>
)}
{active.myRole === 'owner' && (
<Popconfirm
title="确定删除该团队?团队内的智能体会变成 owner 私有"
onConfirm={async () => {
await handleDelete(active.id);
message.success('已删除');
}}
>
<Button danger size="small" icon={<DeleteOutlined />} style={{ borderRadius: 8 }}>
</Button>
</Popconfirm>
)}
</Space>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gap: 10, marginBottom: 16 }}>
<div style={{ borderRadius: 12, padding: '12px 14px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)' }}>{active.members?.length || 0}</div>
</div>
<div style={{ borderRadius: 12, padding: '12px 14px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)' }}>{active.agentCount ?? 0}</div>
</div>
<div style={{ borderRadius: 12, padding: '12px 14px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', textTransform: 'capitalize' }}>{active.myRole}</div>
</div>
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', marginBottom: 10 }}>
({active.members?.length || 0})
</div>
<List
dataSource={active.members || []}
renderItem={(m) => (
<List.Item
style={{ padding: '10px 0' }}
actions={
(active.myRole === 'owner' || active.myRole === 'admin') && m.role !== 'owner'
? [
<Popconfirm
key="kick"
title="移除该成员?"
onConfirm={async () => {
await handleRemoveMember(m.id);
}}
>
<Button size="small" danger style={{ borderRadius: 6 }}>
</Button>
</Popconfirm>,
]
: []
}
>
<List.Item.Meta
avatar={
<div
style={{
width: 36,
height: 36,
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.10)',
color: 'var(--color-brand)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<UserOutlined />
</div>
}
title={
<Space>
<span style={{ fontWeight: 600, fontSize: 14 }}>{m.name}</span>
<Tag
bordered={false}
style={{
background:
m.role === 'owner'
? 'var(--color-warning-soft)'
: m.role === 'admin'
? 'var(--color-info-soft)'
: 'var(--color-surface-2)',
color:
m.role === 'owner'
? 'var(--color-warning)'
: m.role === 'admin'
? 'var(--color-info)'
: 'var(--color-text-secondary)',
borderRadius: 999,
margin: 0,
fontSize: 10,
}}
>
{m.role}
</Tag>
</Space>
}
description={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12 }}>{m.email}</span>
<span style={{ fontSize: 11, color: 'var(--color-text-tertiary)' }}>
{new Date(m.joinedAt).toLocaleDateString('zh-CN')}
</span>
</div>
}
/>
</List.Item>
)}
/>
</Card>
) : (
<Empty description="选择或创建一个团队" style={{ marginTop: 20 }} />
)}
<Modal
open={logic.createOpen}
title="新建团队"
onCancel={() => logic.setCreateOpen(false)}
footer={null}
width="95%"
destroyOnHidden
>
<Form layout="vertical" onFinish={logic.handleCreate}>
<Form.Item name="name" label="团队名称" rules={[{ required: true }]}>
<Input placeholder="如AI 实验小组" autoFocus />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
<Modal
open={logic.inviteOpen}
title={`📨 邀请加入 ${active?.name}`}
onCancel={() => {
logic.setInviteOpen(false);
logic.setLastInviteCode(null);
}}
width="95%"
footer={null}
destroyOnHidden
>
{logic.lastInviteCode ? (
<div>
<div style={{ marginBottom: 12 }}></div>
<Input.TextArea
value={logic.lastInviteCode}
readOnly
autoSize
style={{ fontFamily: 'monospace', fontSize: 14 }}
/>
<Button
type="default"
icon={<CopyOutlined />}
style={{ marginTop: 12, borderRadius: 8 }}
onClick={() => {
navigator.clipboard?.writeText(logic.lastInviteCode || '').then(() => message.success('邀请码已复制'));
}}
>
</Button>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 11, marginTop: 8 }}>
</div>
</div>
) : (
<Form layout="vertical" onFinish={logic.handleInvite}>
<Form.Item name="email" label="限定邮箱(可选)">
<Input placeholder="只允许该邮箱使用此邀请码" />
</Form.Item>
<Form.Item name="ttlHours" label="有效期(小时)" initialValue={168}>
<Input type="number" placeholder="168 = 7 天" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
)}
</Modal>
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div>
);
}

View File

@ -0,0 +1,377 @@
import {
CopyOutlined,
DeleteOutlined,
MailOutlined,
PlusOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
import { Card, Button, List, Tag, Space, Popconfirm, App as AntApp, Modal, Form, Input, Empty } from 'antd';
import type { Team } from '../../../api';
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
interface Props {
logic: TeamsPageLogicOutput;
}
export default function TeamsPageWeb({ logic }: Props) {
const { message } = AntApp.useApp();
const {
list,
active,
handleDelete,
handleRemoveMember,
setCreateOpen,
} = logic;
return (
<div className="feature-cover-container">
<div className="page-container" style={{ maxWidth: 1080 }}>
<div
style={{
borderRadius: 24,
padding: '30px 30px 26px',
background:
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
border: '1px solid rgba(8, 145, 178, 0.12)',
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
marginBottom: 24,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 20,
flexWrap: 'wrap',
marginBottom: 20,
}}
>
<div style={{ maxWidth: 620 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 999,
background: 'rgba(255,255,255,0.78)',
border: '1px solid rgba(8, 145, 178, 0.10)',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 600,
marginBottom: 16,
}}
>
<TeamOutlined style={{ color: 'var(--color-brand)' }} />
</div>
<h1 className="page-title" style={{ marginBottom: 10 }}></h1>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
</div>
</div>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}>
</Button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
{[
{ label: '团队数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '当前成员数', value: active?.members?.length ?? 0, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
{ label: '共享智能体', value: active?.agentCount ?? 0, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 18,
padding: '16px 18px',
background: 'rgba(255,255,255,0.72)',
border: '1px solid rgba(255,255,255,0.7)',
}}
>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
<span
style={{
borderRadius: 999,
padding: '4px 8px',
background: item.tone,
color: item.color,
fontSize: 12,
fontWeight: 600,
}}
>
</span>
</div>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 22, alignItems: 'stretch' }}>
<div
style={{
width: 260,
flexShrink: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
border: '1px solid var(--color-border)',
borderRadius: 22,
padding: 14,
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
}}
>
<div style={{ padding: '8px 10px 14px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}></div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 4 }}></div>
</div>
{list.length === 0 ? (
<Empty description="还没有团队" />
) : (
<List
dataSource={list}
renderItem={(item) => (
<div
className={`nav-item ${active?.id === item.id ? 'active' : ''}`}
onClick={async () => {
logic.setActive(await TeamAPI.detail(item.id));
}}
style={{
padding: '10px 12px',
borderRadius: 14,
cursor: 'pointer',
marginBottom: 6,
background: active?.id === item.id ? 'rgba(8, 145, 178, 0.10)' : 'transparent',
color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-secondary)',
fontWeight: active?.id === item.id ? 600 : 500,
border: active?.id === item.id ? '1px solid rgba(8, 145, 178, 0.16)' : '1px solid transparent',
}}
>
<div style={{ fontSize: 14, marginBottom: 4 }}>{item.name}</div>
<div style={{ fontSize: 12, color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-tertiary)' }}>
{item.agentCount ?? 0}
</div>
</div>
)}
/>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{active ? (
<Card
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
bodyStyle={{ padding: 22 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 20 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)' }}>{active.name}</span>
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0 }}>{active.myRole}</Tag>
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>{active.agentCount ?? 0} </Tag>
</div>
<div style={{ fontSize: 13.5, color: 'var(--color-text-secondary)' }}>
</div>
</div>
<Space>
{(active.myRole === 'owner' || active.myRole === 'admin') && (
<Button icon={<MailOutlined />} onClick={() => logic.setInviteOpen(true)} style={{ borderRadius: 12 }}>
</Button>
)}
{active.myRole === 'owner' && (
<Popconfirm
title="确定删除该团队?团队内的智能体会变成 owner 私有"
onConfirm={async () => {
await handleDelete(active.id);
message.success('已删除');
}}
>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 12 }}>
</Button>
</Popconfirm>
)}
</Space>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 20 }}>
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{active.members?.length || 0}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{active.agentCount ?? 0}</div>
</div>
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}></div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)', textTransform: 'capitalize' }}>{active.myRole}</div>
</div>
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', marginBottom: 14 }}>
({active.members?.length || 0})
</div>
<List
dataSource={active.members || []}
renderItem={(m) => (
<List.Item
style={{ padding: '14px 0' }}
actions={
(active.myRole === 'owner' || active.myRole === 'admin') && m.role !== 'owner'
? [
<Popconfirm
key="kick"
title="移除该成员?"
onConfirm={async () => {
await handleRemoveMember(m.id);
}}
>
<Button size="small" danger style={{ borderRadius: 10 }}>
</Button>
</Popconfirm>,
]
: []
}
>
<List.Item.Meta
avatar={
<div
style={{
width: 42,
height: 42,
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.10)',
color: 'var(--color-brand)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<UserOutlined />
</div>
}
title={
<Space>
<span style={{ fontWeight: 600 }}>{m.name}</span>
<Tag
bordered={false}
style={{
background:
m.role === 'owner'
? 'var(--color-warning-soft)'
: m.role === 'admin'
? 'var(--color-info-soft)'
: 'var(--color-surface-2)',
color:
m.role === 'owner'
? 'var(--color-warning)'
: m.role === 'admin'
? 'var(--color-info)'
: 'var(--color-text-secondary)',
borderRadius: 999,
margin: 0,
}}
>
{m.role}
</Tag>
</Space>
}
description={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span>{m.email}</span>
<span style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{new Date(m.joinedAt).toLocaleDateString('zh-CN')}
</span>
</div>
}
/>
</List.Item>
);
}}
/>
</Card>
) : (
<Empty description="选择或创建一个团队" />
)}
</div>
</div>
<Modal
open={logic.createOpen}
title="新建团队"
onCancel={() => logic.setCreateOpen(false)}
footer={null}
destroyOnHidden
>
<Form layout="vertical" onFinish={logic.handleCreate}>
<Form.Item name="name" label="团队名称" rules={[{ required: true }]}>
<Input placeholder="如AI 实验小组" autoFocus />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
<Modal
open={logic.inviteOpen}
title={`📨 邀请加入 ${active?.name}`}
onCancel={() => {
logic.setInviteOpen(false);
logic.setLastInviteCode(null);
}}
footer={null}
destroyOnHidden
>
{logic.lastInviteCode ? (
<div>
<div style={{ marginBottom: 12 }}></div>
<Input.TextArea
value={logic.lastInviteCode}
readOnly
autoSize
style={{ fontFamily: 'monospace', fontSize: 16 }}
/>
<Button
type="default"
icon={<CopyOutlined />}
style={{ marginTop: 12, borderRadius: 10 }}
onClick={() => {
navigator.clipboard?.writeText(logic.lastInviteCode || '').then(() => message.success('邀请码已复制'));
}}
>
</Button>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 12, marginTop: 8 }}>
</div>
</div>
) : (
<Form layout="vertical" onFinish={logic.handleInvite}>
<Form.Item name="email" label="限定邮箱(可选)">
<Input placeholder="只允许该邮箱使用此邀请码" />
</Form.Item>
<Form.Item name="ttlHours" label="有效期(小时)" initialValue={168}>
<Input type="number" placeholder="168 = 7 天" />
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
)}
</Modal>
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div>
);
}

View File

@ -1,268 +1,24 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { App as AntApp, Empty } from 'antd'; import { useChatPageLogic } from './chat/ChatPageLogic';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import ChatPageWeb from './chat/components/ChatPageWeb';
import type { ModelOverrides } from '../../api'; import ChatPageH5 from './chat/components/ChatPageH5';
import { SessionAPI } from '../../api';
import AgentSidebar from './components/AgentSidebar';
import ChatHeader from './components/ChatHeader';
import ChatBody from './components/ChatBody';
import ChatInput from './components/ChatInput';
import ChatDrawers from './components/ChatDrawers';
import ChatOutline from './components/ChatOutline';
import { useChatScroll } from './hooks/useChatScroll';
import { useChatData } from './hooks/useChatData';
import { useChatSender } from './hooks/useChatSender';
import { markdownToPlainText } from './utils/copy';
const lastRoomKey = (agentId: string) => `chat:lastRoom:${agentId}`; const isMobileDevice = () => {
const isValidRoomId = (v: string | null): v is string => !!v && !String(v).startsWith('legacy_'); if (typeof window === 'undefined') return false;
return window.innerWidth < 768;
};
export default function ChatPage() { export default function ChatPage() {
const { id } = useParams(); const logic = useChatPageLogic();
const navigate = useNavigate(); const [isMobile, setIsMobile] = useState(isMobileDevice());
const [searchParams, setSearchParams] = useSearchParams();
const { message } = AntApp.useApp();
const [roomId, setRoomId] = useState<string | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null);
const [overrides, setOverrides] = useState<ModelOverrides>({});
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const { bodyRef, scrollBottom, initialScrollDoneRef } = useChatScroll();
useEffect(() => { useEffect(() => {
if (!id) return; const handleResize = () => {
abortRef.current?.abort(); setIsMobile(isMobileDevice());
setRoomId(null); };
setHighlightId(null); window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const s = searchParams.get('session'); return isMobile ? <ChatPageH5 logic={logic} /> : <ChatPageWeb logic={logic} />;
const m = searchParams.get('msg');
if (isValidRoomId(s)) {
setRoomId(s);
try {
localStorage.setItem(lastRoomKey(id), s);
} catch {
// ignore
}
if (m) {
setHighlightId(m);
const next = new URLSearchParams(searchParams);
next.delete('msg');
setSearchParams(next, { replace: true });
}
return;
}
if (s && !isValidRoomId(s)) {
const next = new URLSearchParams(searchParams);
next.delete('session');
next.delete('msg');
setSearchParams(next, { replace: true });
}
const saved = (() => {
try {
return localStorage.getItem(lastRoomKey(id));
} catch {
return null;
}
})();
if (isValidRoomId(saved)) {
setRoomId(saved);
return;
}
if (saved && !isValidRoomId(saved)) {
try {
localStorage.removeItem(lastRoomKey(id));
} catch {
// ignore
}
}
(async () => {
try {
const list = await SessionAPI.list(id, '0');
if (list.length > 0) {
setRoomId(list[0].id);
return;
}
const created = await SessionAPI.create(id);
setRoomId(created.id);
} catch (e: any) {
message.error('加载房间失败:' + (e?.message ?? e));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
if (!id || !roomId) return;
try {
localStorage.setItem(lastRoomKey(id), roomId);
} catch {
// ignore
}
const cur = searchParams.get('session');
if (cur === roomId) return;
const next = new URLSearchParams(searchParams);
next.set('session', roomId);
next.delete('msg');
setSearchParams(next, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, roomId]);
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages } = useChatData({
agentId: id,
roomId,
highlightId,
setHighlightId,
scrollBottom,
initialScrollDoneRef,
setOverrides: (updater) => setOverrides(updater),
abort: () => abortRef.current?.abort()
});
const sender = useChatSender({
agentId: id,
agent,
agentList,
roomId,
overrides,
setOverrides: (updater) => setOverrides(updater),
messages,
setMessages,
setBranches,
loadMessages,
scrollBottom,
notify: { success: (t) => message.success(t), error: (t) => message.error(t) },
abortRef
});
return (
<div className="chat-shell">
<AgentSidebar
agentList={agentList}
activeAgentId={id}
onCreate={() => navigate('/agents/new')}
onSelect={(aid) => navigate(`/chat/${aid}`)}
/>
<section className="chat-main">
{!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请在左侧选择一个智能体开始对话" />
</div>
) : (
<>
<ChatHeader
agent={agent}
useStream={sender.useStream}
setUseStream={sender.setUseStream}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onOpenParams={() => setParamsDrawerOpen(true)}
onOpenMcp={() => setMcpDrawerOpen(true)}
onManageAgent={() => navigate(`/agents/${id}`)}
onClear={sender.handleClear}
/>
<div className="chat-content-row">
<ChatBody
bodyRef={bodyRef}
agent={agent}
agentList={agentList}
currentAgentId={id!}
messages={messages}
branches={branches}
highlightId={highlightId}
sending={sender.sending}
streaming={sender.streaming}
onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch}
onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text);
return navigator.clipboard
?.writeText(content)
.then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制(纯文本)'));
}}
/>
<ChatOutline
messages={messages}
activeId={highlightId}
onJump={(msgId) => {
setHighlightId(msgId);
const el = document.getElementById('msg-' + msgId);
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
}}
/>
</div>
<ChatInput
input={sender.input}
setInput={sender.setInput}
sending={sender.sending}
attachments={sender.attachments}
setAttachments={(updater) => sender.setAttachments(updater)}
imageUrls={sender.imageUrls}
setImageUrls={(updater) => sender.setImageUrls(updater)}
onSend={sender.handleSend}
onStop={sender.handleStop}
onAttach={sender.handleAttach}
onOpenTpl={() => setTplDrawerOpen(true)}
modelOptions={sender.modelOptions}
activeModelValue={sender.activeModelValue}
onChangeModel={(modelId) => {
const selected = sender.modelOptions.find((m) => m.value === modelId);
setOverrides((o) => ({ ...o, model_id: String(modelId), model: selected?.label || String(modelId) }));
}}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onNewSession={async () => {
if (!id) return;
abortRef.current?.abort();
try {
const created = await SessionAPI.create(id);
setHighlightId(null);
setMessages(() => []);
setBranches({});
setRoomId(created.id);
} catch (e: any) {
message.error('创建房间失败:' + (e?.message ?? e));
}
}}
agentList={agentList}
onInsertMention={() => {}}
/>
</>
)}
</section>
<ChatDrawers
agentId={id}
agent={agent}
input={sender.input}
setInput={(updater) => sender.setInput(updater(sender.input))}
overrides={overrides}
setOverrides={(updater) => setOverrides(updater)}
roomId={roomId}
setRoomId={setRoomId}
setHighlightId={setHighlightId}
sessionRefresh={sender.sessionRefresh}
mcpDrawerOpen={mcpDrawerOpen}
setMcpDrawerOpen={setMcpDrawerOpen}
tplDrawerOpen={tplDrawerOpen}
setTplDrawerOpen={setTplDrawerOpen}
paramsDrawerOpen={paramsDrawerOpen}
setParamsDrawerOpen={setParamsDrawerOpen}
historyDrawerOpen={historyDrawerOpen}
setHistoryDrawerOpen={setHistoryDrawerOpen}
notify={{ success: (t) => message.success(t) }}
/>
</div>
);
} }

View File

@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { App as AntApp } from 'antd';
import { SessionAPI } from '../../../api';
import { useChatScroll } from './hooks/useChatScroll';
import { useChatData } from './hooks/useChatData';
import { useChatSender } from './hooks/useChatSender';
import { markdownToPlainText } from './utils/copy';
const lastRoomKey = (agentId: string) => `chat:lastRoom:${agentId}`;
const isValidRoomId = (v: string | null): v is string => !!v && !String(v).startsWith('legacy_');
export function useChatPageLogic() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { message } = AntApp.useApp();
const [roomId, setRoomId] = useState<string | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null);
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const { bodyRef, scrollBottom, initialScrollDoneRef } = useChatScroll();
useEffect(() => {
if (!id) return;
abortRef.current?.abort();
setRoomId(null);
setHighlightId(null);
const s = searchParams.get('session');
const m = searchParams.get('msg');
if (isValidRoomId(s)) {
setRoomId(s);
try {
localStorage.setItem(lastRoomKey(id), s);
} catch {
// ignore
}
if (m) {
setHighlightId(m);
const next = new URLSearchParams(searchParams);
next.delete('msg');
setSearchParams(next, { replace: true });
}
return;
}
const saved = (() => {
try {
return localStorage.getItem(lastRoomKey(id));
} catch {
return null;
}
})();
if (isValidRoomId(saved)) {
setRoomId(saved);
return;
}
if (saved && !isValidRoomId(saved)) {
try {
localStorage.removeItem(lastRoomKey(id));
} catch {
// ignore
}
}
(async () => {
try {
const list = await SessionAPI.list(id, '0');
if (list.length > 0) {
setRoomId(list[0].id);
return;
}
const created = await SessionAPI.create(id);
setRoomId(created.id);
} catch (e: any) {
message.error('加载房间失败:' + (e?.message ?? e));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
if (!id || !roomId) return;
try {
localStorage.setItem(lastRoomKey(id), roomId);
} catch {
// ignore
}
const cur = searchParams.get('session');
if (cur === roomId) return;
const next = new URLSearchParams(searchParams);
next.set('session', roomId);
if (!highlightId) {
next.delete('msg');
}
setSearchParams(next, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, roomId]);
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages } = useChatData({
agentId: id,
roomId,
highlightId,
setHighlightId,
scrollBottom,
initialScrollDoneRef,
setOverrides: (updater) => {},
abort: () => abortRef.current?.abort(),
});
const sender = useChatSender({
agentId: id,
agent,
agentList,
roomId,
overrides: {},
messages,
setMessages,
setBranches,
loadMessages,
scrollBottom,
notify: { success: (t) => message.success(t), error: (t) => message.error(t) },
abort: abortRef,
});
return {
id,
roomId,
highlightId,
historyDrawerOpen,
mcpDrawerOpen,
paramsDrawerOpen,
tplDrawerOpen,
agent,
agentList,
messages,
branches,
bodyRef,
scrollBottom,
initialScrollDoneRef,
sender,
navigate,
setHistoryDrawerOpen,
setMcpDrawerOpen,
setParamsDrawerOpen,
setTplDrawerOpen,
};
}
export type ChatPageLogicOutput = ReturnType<typeof useChatPageLogic>;

View File

@ -0,0 +1,134 @@
import { Empty } from 'antd';
import AgentSidebar from './AgentSidebar';
import ChatHeader from './components/ChatHeader';
import ChatBody from './components/ChatBody';
import ChatInput from './components/ChatInput';
import ChatDrawers from './components/ChatDrawers';
import ChatOutline from './components/ChatOutline';
import type { ChatPageLogicOutput } from '../ChatPageLogic';
interface Props {
logic: ChatPageLogicOutput;
}
export default function ChatPageH5({ logic }: Props) {
const {
id,
roomId,
highlightId,
historyDrawerOpen,
mcpDrawerOpen,
paramsDrawerOpen,
tplDrawerOpen,
agent,
agentList,
messages,
branches,
bodyRef,
scrollBottom,
initialScrollDoneRef,
sender,
navigate,
setHistoryDrawerOpen,
setMcpDrawerOpen,
setParamsDrawerOpen,
setTplDrawerOpen,
} = logic;
return (
<div className="chat-shell h5-chat-shell">
<AgentSidebar
agentList={agentList}
activeAgentId={id}
onCreate={() => navigate('/agents/new')}
onSelect={(aid) => navigate(`/chat/${aid}`)}
/>
<section className="chat-main h5-chat-main">
{!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请在左侧选择一个智能体开始对话" />
</div>
) : (
<>
<ChatHeader
agent={agent}
useStream={sender.useStream}
setUseStream={sender.setUseStream}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onOpenParams={() => setParamsDrawerOpen(true)}
onOpenMcp={() => setMcpDrawerOpen(true)}
onManageAgent={() => navigate(`/agents/${id}`)}
onClear={sender.handleClear}
/>
<div className="chat-content-row h5-chat-content-row">
<ChatBody
bodyRef={bodyRef}
agent={agent}
agentList={agentList}
currentAgentId={id!}
messages={messages}
branches={branches}
highlightId={highlightId}
sending={sender.sending}
streaming={sender.streaming}
onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch}
onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text);
return navigator.clipboard
?.writeText(content)
?.then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制(纯文本)'));
}}
/>
/>
<ChatOutline
messages={messages}
activeId={highlightId}
onJump={(msgId) => {
setHighlightId(msgId);
const el = document.getElementById('msg-' + msgId);
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
}}
/>
</div>
<ChatInput
input={sender.input}
setInput={sender.setInput}
sending={sender.sending}
attachments={sender.attachments}
setAttachments={sender.setAttachments}
imageUrls={sender.imageUrls}
setImageUrls={sender.setImageUrls}
onSend={sender.handleSend}
onStop={sender.handleStop}
onAttach={sender.handleAttach}
onOpenTpl={() => setTplDrawerOpen(true)}
modelOptions={sender.modelOptions}
activeModelValue={sender.activeModelValue}
onChangeModel={sender.onChangeModel}
agentList={agentList}
onInsertMention={() => {}}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onNewSession={async () => {
if (!id) return;
abortRef.current?.abort();
try {
const created = await SessionAPI.create(id);
setRoomId(created.id);
messages.length && messages.splice(0, messages.length);
branches && Object.keys(branches).length && Object.values(branches).forEach(() => {});
} catch (e) {
message.error('创建房间失败');
}
}}
/>
</div>
</div>
</>
);
};
}

View File

@ -0,0 +1,154 @@
import { Empty } from 'antd';
import AgentSidebar from './AgentSidebar';
import ChatHeader from './components/ChatHeader';
import ChatBody from './components/ChatBody';
import ChatInput from './components/ChatInput';
import ChatDrawers from './components/ChatDrawers';
import ChatOutline from './components/ChatOutline';
import type { ChatPageLogicOutput } from '../ChatPageLogic';
interface Props {
logic: ChatPageLogicOutput;
}
export default function ChatPageWeb({ logic }: Props) {
const {
id,
roomId,
highlightId,
historyDrawerOpen,
mcpDrawerOpen,
paramsDrawerOpen,
tplDrawerOpen,
agent,
agentList,
messages,
branches,
bodyRef,
scrollBottom,
initialScrollDoneRef,
sender,
navigate,
setHistoryDrawerOpen,
setMcpDrawerOpen,
setParamsDrawerOpen,
setTplDrawerOpen,
} = logic;
return (
<div className="chat-shell">
<AgentSidebar
agentList={agentList}
activeAgentId={id}
onCreate={() => navigate('/agents/new')}
onSelect={(aid) => navigate(`/chat/${aid}`)}
/>
<section className="chat-main">
{!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请在左侧选择一个智能体开始对话" />
</div>
) : (
<>
<ChatHeader
agent={agent}
useStream={sender.useStream}
setUseStream={sender.setUseStream}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onOpenParams={() => setParamsDrawerOpen(true)}
onOpenMcp={() => setMcpDrawerOpen(true)}
onManageAgent={() => navigate(`/agents/${id}`)}
onClear={sender.handleClear}
/>
<div className="chat-content-row">
<ChatBody
bodyRef={bodyRef}
agent={agent}
agentList={agentList}
currentAgentId={id!}
messages={messages}
branches={branches}
highlightId={highlightId}
sending={sender.sending}
streaming={sender.streaming}
onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch}
onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text);
return navigator.clipboard
?.writeText(content)
?.then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制(纯文本)'));
}}
/>
/>
<ChatOutline
messages={messages}
activeId={highlightId}
onJump={(msgId) => {
setHighlightId(msgId);
const el = document.getElementById('msg-' + msgId);
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
}}
/>
</div>
<ChatInput
input={sender.input}
setInput={sender.setInput}
sending={sender.sending}
attachments={sender.attachments}
setAttachments={sender.setAttachments}
imageUrls={sender.imageUrls}
setImageUrls={sender.setImageUrls}
onSend={sender.handleSend}
onStop={sender.handleStop}
onAttach={sender.handleAttach}
onOpenTpl={() => setTplDrawerOpen(true)}
modelOptions={sender.modelOptions}
activeModelValue={sender.activeModelValue}
onChangeModel={sender.onChangeModel}
agentList={agentList}
onInsertMention={() => {}}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onNewSession={async () => {
if (!id) return;
abortRef.current?.abort();
try {
const created = await SessionAPI.create(id);
setRoomId(created.id);
messages.length && messages.splice(0, messages.length);
branches && Object.keys(branches).length && Object.values(branches).forEach(() => {});
} catch (e) {
message.error('创建房间失败');
}
}}
/>
</div>
</>
);
<ChatDrawers
agentId={id}
agent={agent}
input={sender.input}
setInput={(updater) => sender.setInput(updater(sender.input))}
roomId={roomId}
setRoomId={setRoomId}
setHighlightId={setHighlightId}
sessionRefresh={sender.sessionRefresh}
mcpDrawerOpen={mcpDrawerOpen}
setMcpDrawerOpen={setMcpDrawerOpen}
tplDrawerOpen={tplDrawerOpen}
setTplDrawerOpen={setTplDrawerOpen}
paramsDrawerOpen={paramsDrawerOpen}
setParamsDrawerOpen={setParamsDrawerOpen}
historyDrawerOpen={historyDrawerOpen}
setHistoryDrawerOpen={setHistoryDrawerOpen}
notify={{ success: (t) => message.success(t) }}
/>
}
};
};