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 { Button, Card, List, Modal, Form, Input, Select, Tag, Space, Popconfirm, App as AntApp, Tooltip, Alert } from 'antd';
|
||||
import { McpAPI, McpServer, McpStatus } from '../api';
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMcpPanelLogic } from './McpPanel/McpPanelLogic';
|
||||
import McpPanelWeb from './McpPanel/components/McpPanelWeb';
|
||||
import McpPanelH5 from './McpPanel/components/McpPanelH5';
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
export default function McpPanel({ agentId }: Props) {
|
||||
const logic = useMcpPanelLogic({ agentId });
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(isMobileDevice());
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return isMobile ? <McpPanelH5 agentId={agentId} logic={logic} /> : <McpPanelWeb agentId={agentId} logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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 { Agent, AgentAPI } from '../api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAgentListLogic } from './AgentList/AgentListLogic';
|
||||
import AgentListWeb from './AgentList/components/AgentListWeb';
|
||||
import AgentListH5 from './AgentList/components/AgentListH5';
|
||||
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
export default function AgentList() {
|
||||
const [list, setList] = useState<Agent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { message } = AntApp.useApp();
|
||||
const logic = useAgentListLogic();
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setList(await AgentAPI.list());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
const handleResize = () => {
|
||||
setIsMobile(isMobileDevice());
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
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>
|
||||
);
|
||||
return isMobile ? <AgentListH5 logic={logic} /> : <AgentListWeb logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { Col, Row, Empty, Button, Tag, Space, App as AntApp, Input, Spin } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MarketplaceAPI, MarketplaceAgent } from '../api';
|
||||
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>
|
||||
import { useMarketplacePageLogic } from './MarketplacePage/MarketplacePageLogic';
|
||||
import MarketplacePageWeb from './MarketplacePage/components/MarketplacePageWeb';
|
||||
import MarketplacePageH5 from './MarketplacePage/components/MarketplacePageH5';
|
||||
|
||||
<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>
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
{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>
|
||||
export default function MarketplacePage() {
|
||||
const logic = useMarketplacePageLogic();
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
|
||||
<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>
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(isMobileDevice());
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
<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>
|
||||
);
|
||||
return isMobile ? <MarketplacePageH5 logic={logic} /> : <MarketplacePageWeb logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { Button, Card, Empty, Input, Select, Space, Spin, Tag, Form, message } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../../api';
|
||||
import { MOCK_OVERVIEW, MOCK_PRODUCTS } from './mocks';
|
||||
import type { ExchangeFormValues, SortKey } from './types';
|
||||
import ExchangeModal from './components/ExchangeModal';
|
||||
import ConfirmExchangeModal from './components/ConfirmExchangeModal';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePointsMallPageLogic } from './PointsMallPageLogic';
|
||||
import PointsMallPageWeb from './components/PointsMallPageWeb';
|
||||
import PointsMallPageH5 from './components/PointsMallPageH5';
|
||||
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
export default function PointsMallPage() {
|
||||
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 [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);
|
||||
}
|
||||
};
|
||||
const logic = usePointsMallPageLogic();
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
|
||||
useEffect(() => {
|
||||
loadOverview();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleResize = () => {
|
||||
setIsMobile(isMobileDevice());
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
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>
|
||||
);
|
||||
return isMobile ? <PointsMallPageH5 logic={logic} /> : <PointsMallPageWeb logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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 { 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>
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTeamsPageLogic } from './TeamsPage/TeamsPageLogic';
|
||||
import TeamsPageWeb from './TeamsPage/components/TeamsPageWeb';
|
||||
import TeamsPageH5 from './TeamsPage/components/TeamsPageH5';
|
||||
|
||||
<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)' }}>选择一个团队查看成员与邀请</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>
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
<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>
|
||||
export default function TeamsPage() {
|
||||
const logic = useTeamsPageLogic();
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(isMobileDevice());
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return isMobile ? <TeamsPageH5 logic={logic} /> : <TeamsPageWeb logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { App as AntApp, Empty } from 'antd';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import type { ModelOverrides } from '../../api';
|
||||
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';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useChatPageLogic } from './chat/ChatPageLogic';
|
||||
import ChatPageWeb from './chat/components/ChatPageWeb';
|
||||
import ChatPageH5 from './chat/components/ChatPageH5';
|
||||
|
||||
const lastRoomKey = (agentId: string) => `chat:lastRoom:${agentId}`;
|
||||
const isValidRoomId = (v: string | null): v is string => !!v && !String(v).startsWith('legacy_');
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
export default function ChatPage() {
|
||||
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 [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();
|
||||
const logic = useChatPageLogic();
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
abortRef.current?.abort();
|
||||
setRoomId(null);
|
||||
setHighlightId(null);
|
||||
const handleResize = () => {
|
||||
setIsMobile(isMobileDevice());
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
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>
|
||||
);
|
||||
return isMobile ? <ChatPageH5 logic={logic} /> : <ChatPageWeb logic={logic} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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