refactor: split each page into common + web/h5 device specific parts for mobile adaptation
parent
0832930bcb
commit
423fc9531f
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue