@yuku123/z-agent-frontend-component 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/dist/z-agent-frontend-component.css +1 -0
- package/dist/z-agent-frontend-component.es.js +9956 -0
- package/dist/z-agent-frontend-component.umd.js +219 -0
- package/package.json +77 -0
- package/src/api/apiRouter.js +78 -0
- package/src/api/index.js +23 -0
- package/src/api/request.js +59 -0
- package/src/api/routes.js +140 -0
- package/src/dev.jsx +80 -0
- package/src/index.js +86 -0
- package/src/pages/agent/app/index.jsx +2 -0
- package/src/pages/agent/editor/AgentAppEditor.jsx +456 -0
- package/src/pages/agent/editor/WorkflowEditor.jsx +495 -0
- package/src/pages/agent/editor/nodes/index.ts +225 -0
- package/src/pages/agent/index.jsx +1379 -0
- package/src/pages/agent/share.jsx +512 -0
- package/src/pages/ak/AkUsageDrawer.jsx +208 -0
- package/src/pages/ak/index.jsx +496 -0
- package/src/pages/llm/index.jsx +736 -0
- package/src/pages/llm/model/index.jsx +220 -0
- package/src/pages/llm/provider/index.jsx +173 -0
- package/src/pages/mcp/index.jsx +359 -0
- package/src/pages/oss/BucketList.jsx +320 -0
- package/src/pages/oss/ObjectBrowser.jsx +409 -0
- package/src/pages/product/execute.jsx +608 -0
- package/src/pages/product/index.jsx +628 -0
- package/src/pages/product/scene.jsx +746 -0
- package/src/pages/script/ApiBridgeEditor.jsx +255 -0
- package/src/pages/script/CurlImportModal.jsx +263 -0
- package/src/pages/script/FieldMappingEditor.jsx +131 -0
- package/src/pages/script/OpenApiImportModal.jsx +212 -0
- package/src/pages/script/index.jsx +532 -0
- package/src/pages/skill/index.jsx +1595 -0
- package/src/pages/trace/DebugPlayground.jsx +357 -0
- package/src/pages/trace/components/MetricsDashboard.jsx +164 -0
- package/src/pages/trace/components/RagFragments.jsx +134 -0
- package/src/pages/trace/components/Timeline.jsx +142 -0
- package/src/pages/trace/components/ToolCallTree.jsx +116 -0
- package/src/pages/trace/index.jsx +13 -0
- package/src/pages/usage/index.jsx +352 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import {useEffect, useState} from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Card,
|
|
5
|
+
Descriptions,
|
|
6
|
+
Form,
|
|
7
|
+
Input,
|
|
8
|
+
InputNumber,
|
|
9
|
+
message,
|
|
10
|
+
Modal,
|
|
11
|
+
Popconfirm,
|
|
12
|
+
Select,
|
|
13
|
+
Space,
|
|
14
|
+
Table,
|
|
15
|
+
Tabs,
|
|
16
|
+
Tag,
|
|
17
|
+
Typography
|
|
18
|
+
} from 'antd'
|
|
19
|
+
import {
|
|
20
|
+
ApiOutlined,
|
|
21
|
+
DeleteOutlined,
|
|
22
|
+
LinkOutlined,
|
|
23
|
+
PlayCircleOutlined,
|
|
24
|
+
PlusOutlined,
|
|
25
|
+
ReloadOutlined
|
|
26
|
+
} from '@ant-design/icons'
|
|
27
|
+
import {mcpApi} from '../../api'
|
|
28
|
+
|
|
29
|
+
const {TextArea} = Input
|
|
30
|
+
const {Text} = Typography
|
|
31
|
+
|
|
32
|
+
export default function McpPage() {
|
|
33
|
+
const [activeTab, setActiveTab] = useState('servers')
|
|
34
|
+
const [servers, setServers] = useState([])
|
|
35
|
+
const [loading, setLoading] = useState(false)
|
|
36
|
+
const [modalOpen, setModalOpen] = useState(false)
|
|
37
|
+
const [editing, setEditing] = useState(null)
|
|
38
|
+
const [form] = Form.useForm()
|
|
39
|
+
|
|
40
|
+
// Tool test state
|
|
41
|
+
const [selectedServer, setSelectedServer] = useState(null)
|
|
42
|
+
const [tools, setTools] = useState([])
|
|
43
|
+
const [selectedTool, setSelectedTool] = useState(null)
|
|
44
|
+
const [toolArgs, setToolArgs] = useState('{}')
|
|
45
|
+
const [toolResult, setToolResult] = useState('')
|
|
46
|
+
const [toolRunning, setToolRunning] = useState(false)
|
|
47
|
+
const [testResult, setTestResult] = useState(null)
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
loadServers()
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
const loadServers = async () => {
|
|
54
|
+
setLoading(true)
|
|
55
|
+
try {
|
|
56
|
+
const tenant = localStorage.getItem('z_tenant') || 'default'
|
|
57
|
+
const res = await mcpApi.list(tenant)
|
|
58
|
+
// 兼容:API 可能返回 404 或空数据
|
|
59
|
+
setServers(Array.isArray(res) ? res : (res?.data || res || []))
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// API 不存在时显示空状态,不报错
|
|
62
|
+
console.warn('MCP API 不可用:', e?.message)
|
|
63
|
+
setServers([])
|
|
64
|
+
} finally {
|
|
65
|
+
setLoading(false)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const handleSave = async () => {
|
|
70
|
+
try {
|
|
71
|
+
const values = await form.validateFields()
|
|
72
|
+
const data = {...values}
|
|
73
|
+
if (editing) {
|
|
74
|
+
data.id = editing.id
|
|
75
|
+
await mcpApi.update(data)
|
|
76
|
+
message.success('更新成功')
|
|
77
|
+
} else {
|
|
78
|
+
await mcpApi.create(data)
|
|
79
|
+
message.success('创建成功')
|
|
80
|
+
}
|
|
81
|
+
setModalOpen(false)
|
|
82
|
+
setEditing(null)
|
|
83
|
+
form.resetFields()
|
|
84
|
+
loadServers()
|
|
85
|
+
} catch (e) { /* validation error */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const handleEdit = (record) => {
|
|
90
|
+
setEditing(record)
|
|
91
|
+
form.setFieldsValue(record)
|
|
92
|
+
setModalOpen(true)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const handleDelete = async (id) => {
|
|
96
|
+
await mcpApi.delete(id)
|
|
97
|
+
message.success('已删除')
|
|
98
|
+
loadServers()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const handleTest = async (record) => {
|
|
102
|
+
setTestResult({loading: true})
|
|
103
|
+
try {
|
|
104
|
+
const res = await mcpApi.test(record.id)
|
|
105
|
+
setTestResult(res?.data || res)
|
|
106
|
+
} catch (e) {
|
|
107
|
+
setTestResult({connected: false, error: e.message})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const handleSelectServer = async (record) => {
|
|
112
|
+
setSelectedServer(record)
|
|
113
|
+
setSelectedTool(null)
|
|
114
|
+
setToolResult('')
|
|
115
|
+
try {
|
|
116
|
+
const res = await mcpApi.listTools(record.id)
|
|
117
|
+
const data = res?.data || res
|
|
118
|
+
setTools(data?.tools || [])
|
|
119
|
+
} catch (e) {
|
|
120
|
+
setTools([])
|
|
121
|
+
message.error('获取工具列表失败')
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const handleSelectTool = (tool) => {
|
|
126
|
+
setSelectedTool(tool)
|
|
127
|
+
// Build default args from schema
|
|
128
|
+
const schema = tool.inputSchema
|
|
129
|
+
if (schema?.properties) {
|
|
130
|
+
const defaults = {}
|
|
131
|
+
Object.keys(schema.properties).forEach(k => {
|
|
132
|
+
defaults[k] = ''
|
|
133
|
+
})
|
|
134
|
+
setToolArgs(JSON.stringify(defaults, null, 2))
|
|
135
|
+
} else {
|
|
136
|
+
setToolArgs('{}')
|
|
137
|
+
}
|
|
138
|
+
setToolResult('')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const handleRunTool = async () => {
|
|
142
|
+
if (!selectedServer || !selectedTool) return
|
|
143
|
+
setToolRunning(true)
|
|
144
|
+
setToolResult('Running...')
|
|
145
|
+
try {
|
|
146
|
+
let args = {}
|
|
147
|
+
try {
|
|
148
|
+
args = JSON.parse(toolArgs)
|
|
149
|
+
} catch (e) {
|
|
150
|
+
}
|
|
151
|
+
const res = await mcpApi.callTool(selectedServer.id, selectedTool.name, args)
|
|
152
|
+
const data = res?.data || res
|
|
153
|
+
if (data?.content && data.content.length > 0) {
|
|
154
|
+
setToolResult(data.content[0].text || JSON.stringify(data.content, null, 2))
|
|
155
|
+
} else {
|
|
156
|
+
setToolResult(JSON.stringify(data, null, 2))
|
|
157
|
+
}
|
|
158
|
+
} catch (e) {
|
|
159
|
+
setToolResult('Error: ' + e.message)
|
|
160
|
+
} finally {
|
|
161
|
+
setToolRunning(false)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ===== Server columns =====
|
|
166
|
+
const columns = [
|
|
167
|
+
{title: '名称', dataIndex: 'serverName', key: 'serverName', width: 140},
|
|
168
|
+
{
|
|
169
|
+
title: '类型', dataIndex: 'transportType', key: 'transportType', width: 80,
|
|
170
|
+
render: (v) => <Tag color={v === 'STDIO' ? 'purple' : 'blue'}>{v || 'HTTP'}</Tag>
|
|
171
|
+
},
|
|
172
|
+
{title: 'URL', dataIndex: 'url', key: 'url', ellipsis: true},
|
|
173
|
+
{
|
|
174
|
+
title: '状态', dataIndex: 'status', key: 'status', width: 80,
|
|
175
|
+
render: (v) => <Tag color={v === 'active' ? 'green' : 'default'}>{v || 'active'}</Tag>
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
title: '操作', key: 'actions', width: 260, render: (_, r) => (
|
|
179
|
+
<Space size="small">
|
|
180
|
+
<Button size="small" icon={<ApiOutlined/>} onClick={() => handleSelectServer(r)}>工具</Button>
|
|
181
|
+
<Button size="small" icon={<LinkOutlined/>} onClick={() => handleTest(r)}>测试</Button>
|
|
182
|
+
<Button size="small" onClick={() => handleEdit(r)}>编辑</Button>
|
|
183
|
+
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
|
|
184
|
+
<Button size="small" danger icon={<DeleteOutlined/>}/>
|
|
185
|
+
</Popconfirm>
|
|
186
|
+
</Space>
|
|
187
|
+
)
|
|
188
|
+
},
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div style={{height: 'calc(100vh - 180px)', display: 'flex', flexDirection: 'column'}}>
|
|
193
|
+
<Tabs activeKey={activeTab} onChange={setActiveTab} tabBarExtraContent={
|
|
194
|
+
<Button type="primary" icon={<PlusOutlined/>} onClick={() => {
|
|
195
|
+
setEditing(null);
|
|
196
|
+
form.resetFields();
|
|
197
|
+
setModalOpen(true)
|
|
198
|
+
}}>
|
|
199
|
+
添加服务
|
|
200
|
+
</Button>
|
|
201
|
+
}>
|
|
202
|
+
<Tabs.TabPane tab="服务管理" key="servers"/>
|
|
203
|
+
<Tabs.TabPane tab="工具测试" key="test" disabled={!selectedServer}/>
|
|
204
|
+
</Tabs>
|
|
205
|
+
|
|
206
|
+
{activeTab === 'servers' && (
|
|
207
|
+
<div style={{flex: 1, display: 'flex', gap: 16, overflow: 'hidden'}}>
|
|
208
|
+
{/* Server List */}
|
|
209
|
+
<div style={{flex: selectedServer ? '0 0 50%' : 1, overflow: 'auto'}}>
|
|
210
|
+
<Table columns={columns} dataSource={servers} rowKey="id" loading={loading}
|
|
211
|
+
size="small" pagination={false}
|
|
212
|
+
onRow={(r) => ({
|
|
213
|
+
onClick: () => handleSelectServer(r),
|
|
214
|
+
style: {
|
|
215
|
+
background: selectedServer?.id === r.id ? '#e6f7ff' : undefined,
|
|
216
|
+
cursor: 'pointer'
|
|
217
|
+
}
|
|
218
|
+
})}/>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Tool Explorer */}
|
|
222
|
+
{selectedServer && (
|
|
223
|
+
<div style={{
|
|
224
|
+
flex: '0 0 50%',
|
|
225
|
+
borderLeft: '1px solid #f0f0f0',
|
|
226
|
+
paddingLeft: 16,
|
|
227
|
+
overflow: 'auto'
|
|
228
|
+
}}>
|
|
229
|
+
<div style={{
|
|
230
|
+
marginBottom: 12,
|
|
231
|
+
display: 'flex',
|
|
232
|
+
justifyContent: 'space-between',
|
|
233
|
+
alignItems: 'center'
|
|
234
|
+
}}>
|
|
235
|
+
<Text strong style={{fontSize: 15}}>{selectedServer.serverName} — 工具列表</Text>
|
|
236
|
+
<Button size="small" icon={<ReloadOutlined/>}
|
|
237
|
+
onClick={() => handleSelectServer(selectedServer)}>刷新</Button>
|
|
238
|
+
</div>
|
|
239
|
+
{tools.length === 0 ? (
|
|
240
|
+
<div style={{textAlign: 'center', padding: 40, color: '#999'}}>暂无工具</div>
|
|
241
|
+
) : (
|
|
242
|
+
tools.map(t => (
|
|
243
|
+
<Card key={t.name} size="small" hoverable
|
|
244
|
+
style={{
|
|
245
|
+
marginBottom: 8,
|
|
246
|
+
borderColor: selectedTool?.name === t.name ? '#1890ff' : undefined
|
|
247
|
+
}}
|
|
248
|
+
onClick={() => {
|
|
249
|
+
handleSelectTool(t);
|
|
250
|
+
setActiveTab('test')
|
|
251
|
+
}}>
|
|
252
|
+
<div style={{
|
|
253
|
+
display: 'flex',
|
|
254
|
+
justifyContent: 'space-between',
|
|
255
|
+
alignItems: 'center'
|
|
256
|
+
}}>
|
|
257
|
+
<div>
|
|
258
|
+
<Text strong>{t.name}</Text>
|
|
259
|
+
<br/><Text type="secondary"
|
|
260
|
+
style={{fontSize: 12}}>{t.description}</Text>
|
|
261
|
+
</div>
|
|
262
|
+
<Tag color="blue" style={{cursor: 'pointer'}}
|
|
263
|
+
onClick={(e) => {
|
|
264
|
+
e.stopPropagation();
|
|
265
|
+
handleSelectTool(t);
|
|
266
|
+
setActiveTab('test')
|
|
267
|
+
}}>
|
|
268
|
+
测试 →
|
|
269
|
+
</Tag>
|
|
270
|
+
</div>
|
|
271
|
+
</Card>
|
|
272
|
+
))
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
{activeTab === 'test' && selectedTool && (
|
|
280
|
+
<div style={{flex: 1, display: 'flex', gap: 16, overflow: 'hidden'}}>
|
|
281
|
+
{/* Input */}
|
|
282
|
+
<div style={{flex: '0 0 40%', display: 'flex', flexDirection: 'column'}}>
|
|
283
|
+
<div style={{marginBottom: 8}}>
|
|
284
|
+
<Text strong>{selectedTool.name}</Text>
|
|
285
|
+
<Text type="secondary" style={{marginLeft: 8}}>{selectedTool.description}</Text>
|
|
286
|
+
</div>
|
|
287
|
+
<Text type="secondary" style={{marginBottom: 4}}>参数 (JSON):</Text>
|
|
288
|
+
<TextArea rows={8} value={toolArgs} onChange={e => setToolArgs(e.target.value)}
|
|
289
|
+
style={{fontFamily: 'monospace', fontSize: 13}}/>
|
|
290
|
+
<Button type="primary" icon={<PlayCircleOutlined/>} onClick={handleRunTool}
|
|
291
|
+
loading={toolRunning} style={{marginTop: 12}}>执行</Button>
|
|
292
|
+
</div>
|
|
293
|
+
{/* Output */}
|
|
294
|
+
<div style={{flex: '0 0 60%', display: 'flex', flexDirection: 'column'}}>
|
|
295
|
+
<Text type="secondary" style={{marginBottom: 4}}>结果:</Text>
|
|
296
|
+
<pre style={{
|
|
297
|
+
flex: 1, overflow: 'auto', background: '#f5f5f5', padding: 12, borderRadius: 4,
|
|
298
|
+
fontFamily: 'monospace', fontSize: 13, margin: 0, whiteSpace: 'pre-wrap'
|
|
299
|
+
}}>
|
|
300
|
+
{toolResult || '点击"执行"查看结果'}
|
|
301
|
+
</pre>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{/* Test Result Modal */}
|
|
307
|
+
<Modal title={`测试连接 - ${testResult ? '' : ''}`} open={!!testResult} onCancel={() => setTestResult(null)}
|
|
308
|
+
footer={<Button onClick={() => setTestResult(null)}>关闭</Button>}>
|
|
309
|
+
{testResult?.loading ? <Text>测试中...</Text> : testResult ? (
|
|
310
|
+
<Descriptions column={1} size="small">
|
|
311
|
+
<Descriptions.Item label="连接状态">
|
|
312
|
+
<Tag
|
|
313
|
+
color={testResult.connected ? 'green' : 'red'}>{testResult.connected ? '成功' : '失败'}</Tag>
|
|
314
|
+
</Descriptions.Item>
|
|
315
|
+
{testResult.serverInfo && <Descriptions.Item label="服务信息">
|
|
316
|
+
{testResult.serverInfo?.name} v{testResult.serverInfo?.version}
|
|
317
|
+
</Descriptions.Item>}
|
|
318
|
+
{testResult.toolCount !== undefined &&
|
|
319
|
+
<Descriptions.Item label="工具数量">{testResult.toolCount}</Descriptions.Item>}
|
|
320
|
+
{testResult.error && <Descriptions.Item label="错误">{testResult.error}</Descriptions.Item>}
|
|
321
|
+
</Descriptions>
|
|
322
|
+
) : null}
|
|
323
|
+
</Modal>
|
|
324
|
+
|
|
325
|
+
{/* Add/Edit Modal */}
|
|
326
|
+
<Modal title={editing ? '编辑 MCP 服务' : '添加 MCP 服务'} open={modalOpen}
|
|
327
|
+
onOk={handleSave} onCancel={() => {
|
|
328
|
+
setModalOpen(false);
|
|
329
|
+
setEditing(null);
|
|
330
|
+
form.resetFields()
|
|
331
|
+
}}>
|
|
332
|
+
<Form form={form} layout="vertical"
|
|
333
|
+
initialValues={{transportType: 'HTTP', timeout: 60, status: 'active'}}>
|
|
334
|
+
<Form.Item name="serverName" label="服务名称" rules={[{required: true}]}>
|
|
335
|
+
<Input placeholder="如: z-agent-mcp-impl-db"/>
|
|
336
|
+
</Form.Item>
|
|
337
|
+
<Form.Item name="transportType" label="传输类型">
|
|
338
|
+
<Select options={[{label: 'HTTP', value: 'HTTP'}, {label: 'STDIO', value: 'STDIO'}]}/>
|
|
339
|
+
</Form.Item>
|
|
340
|
+
<Form.Item name="url" label="URL" rules={[{required: true}]}>
|
|
341
|
+
<Input placeholder="http://localhost:8095/mcp"/>
|
|
342
|
+
</Form.Item>
|
|
343
|
+
<Form.Item name="authToken" label="Auth Token">
|
|
344
|
+
<Input.Password placeholder="Bearer token (可选)"/>
|
|
345
|
+
</Form.Item>
|
|
346
|
+
<Form.Item name="timeout" label="超时(秒)">
|
|
347
|
+
<InputNumber min={5} max={300} style={{width: '100%'}}/>
|
|
348
|
+
</Form.Item>
|
|
349
|
+
<Form.Item name="status" label="状态">
|
|
350
|
+
<Select options={[{label: '启用', value: 'active'}, {label: '停用', value: 'inactive'}]}/>
|
|
351
|
+
</Form.Item>
|
|
352
|
+
<Form.Item name="remark" label="备注">
|
|
353
|
+
<TextArea rows={2}/>
|
|
354
|
+
</Form.Item>
|
|
355
|
+
</Form>
|
|
356
|
+
</Modal>
|
|
357
|
+
</div>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import {useCallback, useEffect, useState} from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Card,
|
|
5
|
+
Col,
|
|
6
|
+
Dropdown,
|
|
7
|
+
Empty,
|
|
8
|
+
Form,
|
|
9
|
+
Input,
|
|
10
|
+
message,
|
|
11
|
+
Modal,
|
|
12
|
+
Popconfirm,
|
|
13
|
+
Row,
|
|
14
|
+
Select,
|
|
15
|
+
Space,
|
|
16
|
+
Spin,
|
|
17
|
+
Statistic,
|
|
18
|
+
Tag,
|
|
19
|
+
Tooltip
|
|
20
|
+
} from 'antd'
|
|
21
|
+
import {
|
|
22
|
+
ApartmentOutlined,
|
|
23
|
+
AppstoreOutlined,
|
|
24
|
+
CloudOutlined,
|
|
25
|
+
DatabaseOutlined,
|
|
26
|
+
DeleteOutlined,
|
|
27
|
+
EyeOutlined,
|
|
28
|
+
FolderOutlined,
|
|
29
|
+
GlobalOutlined,
|
|
30
|
+
LockOutlined,
|
|
31
|
+
MoreOutlined,
|
|
32
|
+
PlusOutlined,
|
|
33
|
+
ReloadOutlined,
|
|
34
|
+
TeamOutlined
|
|
35
|
+
} from '@ant-design/icons'
|
|
36
|
+
import {ossApi} from '../../api'
|
|
37
|
+
import {useNavigate} from 'react-router-dom'
|
|
38
|
+
|
|
39
|
+
const ACL_LABELS = {
|
|
40
|
+
'private': {label: '私有', color: 'default', icon: <LockOutlined/>},
|
|
41
|
+
'public-read': {label: '公共读', color: 'blue', icon: <EyeOutlined/>},
|
|
42
|
+
'public-read-write': {label: '公共读写', color: 'orange', icon: <GlobalOutlined/>},
|
|
43
|
+
'authenticated-read': {label: '认证读', color: 'purple', icon: <TeamOutlined/>},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function BucketList() {
|
|
47
|
+
const [buckets, setBuckets] = useState([])
|
|
48
|
+
const [loading, setLoading] = useState(false)
|
|
49
|
+
const [createOpen, setCreateOpen] = useState(false)
|
|
50
|
+
const [createForm] = Form.useForm()
|
|
51
|
+
const [creating, setCreating] = useState(false)
|
|
52
|
+
const navigate = useNavigate()
|
|
53
|
+
|
|
54
|
+
const loadBuckets = useCallback(async () => {
|
|
55
|
+
setLoading(true)
|
|
56
|
+
try {
|
|
57
|
+
const res = await ossApi.listBuckets()
|
|
58
|
+
const list = Array.isArray(res) ? res : (res?.data || [])
|
|
59
|
+
setBuckets(list)
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.error('loadBuckets error:', e)
|
|
62
|
+
message.error('加载桶列表失败:' + (e?.message || '未知错误'))
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false)
|
|
65
|
+
}
|
|
66
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
loadBuckets()
|
|
70
|
+
}, [loadBuckets])
|
|
71
|
+
|
|
72
|
+
const handleCreate = async () => {
|
|
73
|
+
try {
|
|
74
|
+
const vals = await createForm.validateFields()
|
|
75
|
+
setCreating(true)
|
|
76
|
+
await ossApi.createBucket(vals)
|
|
77
|
+
message.success('桶创建成功')
|
|
78
|
+
setCreateOpen(false)
|
|
79
|
+
createForm.resetFields()
|
|
80
|
+
loadBuckets()
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (e?.errorFields) return
|
|
83
|
+
message.error('创建失败:' + (e?.message || '未知错误'))
|
|
84
|
+
} finally {
|
|
85
|
+
setCreating(false)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const handleDelete = async (name) => {
|
|
90
|
+
try {
|
|
91
|
+
await ossApi.deleteBucket(name)
|
|
92
|
+
message.success('已删除')
|
|
93
|
+
loadBuckets()
|
|
94
|
+
} catch (e) {
|
|
95
|
+
message.error('删除失败:' + (e?.message || '未知错误'))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const handleSetAcl = async (name, acl) => {
|
|
100
|
+
try {
|
|
101
|
+
await ossApi.setBucketAcl(name, acl)
|
|
102
|
+
message.success('ACL 已更新')
|
|
103
|
+
loadBuckets()
|
|
104
|
+
} catch (e) {
|
|
105
|
+
message.error('设置 ACL 失败:' + (e?.message || '未知错误'))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const aclMenu = (name) => ({
|
|
110
|
+
items: Object.keys(ACL_LABELS).map(acl => ({
|
|
111
|
+
key: acl,
|
|
112
|
+
label: ACL_LABELS[acl].label,
|
|
113
|
+
icon: ACL_LABELS[acl].icon,
|
|
114
|
+
})),
|
|
115
|
+
onClick: ({key}) => handleSetAcl(name, key),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div>
|
|
120
|
+
{/* 顶部工具栏 */}
|
|
121
|
+
<div style={{
|
|
122
|
+
display: 'flex', alignItems: 'center', gap: 12,
|
|
123
|
+
background: '#fff', padding: '14px 16px', borderRadius: 10,
|
|
124
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.04)', marginBottom: 16,
|
|
125
|
+
}}>
|
|
126
|
+
<DatabaseOutlined style={{fontSize: 18, color: '#1890ff'}}/>
|
|
127
|
+
<span style={{fontSize: 16, fontWeight: 600}}>对象存储</span>
|
|
128
|
+
<Tag color="blue">OSS Buckets</Tag>
|
|
129
|
+
<div style={{flex: 1}}/>
|
|
130
|
+
<Button icon={<ReloadOutlined/>} onClick={loadBuckets}>刷新</Button>
|
|
131
|
+
<Button
|
|
132
|
+
type="primary"
|
|
133
|
+
icon={<PlusOutlined/>}
|
|
134
|
+
onClick={() => setCreateOpen(true)}
|
|
135
|
+
>
|
|
136
|
+
创建桶
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{/* 统计卡片 */}
|
|
141
|
+
<Row gutter={16} style={{marginBottom: 16}}>
|
|
142
|
+
<Col span={8}>
|
|
143
|
+
<Card>
|
|
144
|
+
<Statistic title="桶总数" value={buckets.length}
|
|
145
|
+
prefix={<ApartmentOutlined style={{color: '#1890ff'}}/>}/>
|
|
146
|
+
</Card>
|
|
147
|
+
</Col>
|
|
148
|
+
<Col span={8}>
|
|
149
|
+
<Card>
|
|
150
|
+
<Statistic title="公共读" value={buckets.filter(b => b.acl === 'public-read').length}
|
|
151
|
+
valueStyle={{color: '#1890ff'}} prefix={<EyeOutlined/>}/>
|
|
152
|
+
</Card>
|
|
153
|
+
</Col>
|
|
154
|
+
<Col span={8}>
|
|
155
|
+
<Card>
|
|
156
|
+
<Statistic title="私有" value={buckets.filter(b => !b.acl || b.acl === 'private').length}
|
|
157
|
+
valueStyle={{color: '#999'}} prefix={<LockOutlined/>}/>
|
|
158
|
+
</Card>
|
|
159
|
+
</Col>
|
|
160
|
+
</Row>
|
|
161
|
+
|
|
162
|
+
{loading ? (
|
|
163
|
+
<div style={{textAlign: 'center', padding: 80}}><Spin size="large"/></div>
|
|
164
|
+
) : buckets.length === 0 ? (
|
|
165
|
+
<Empty
|
|
166
|
+
image={<CloudOutlined style={{fontSize: 64, color: '#bfbfbf'}}/>}
|
|
167
|
+
description={
|
|
168
|
+
<div>
|
|
169
|
+
<div style={{fontSize: 16, marginBottom: 8}}>还没有桶</div>
|
|
170
|
+
<div style={{color: '#999', fontSize: 13, marginBottom: 16}}>
|
|
171
|
+
创建一个桶来开始使用对象存储
|
|
172
|
+
</div>
|
|
173
|
+
<Button type="primary" icon={<PlusOutlined/>} onClick={() => setCreateOpen(true)}>
|
|
174
|
+
创建第一个桶
|
|
175
|
+
</Button>
|
|
176
|
+
</div>
|
|
177
|
+
}
|
|
178
|
+
style={{padding: 80, background: '#fff', borderRadius: 10}}
|
|
179
|
+
/>
|
|
180
|
+
) : (
|
|
181
|
+
<Row gutter={[16, 16]}>
|
|
182
|
+
{buckets.map(bucket => {
|
|
183
|
+
const acl = ACL_LABELS[bucket.acl] || ACL_LABELS.private
|
|
184
|
+
return (
|
|
185
|
+
<Col key={bucket.id || bucket.name} xs={24} sm={12} md={8} lg={6}>
|
|
186
|
+
<Card
|
|
187
|
+
hoverable
|
|
188
|
+
onClick={() => navigate(`/ai/oss/bucket/${encodeURIComponent(bucket.name)}`)}
|
|
189
|
+
style={{
|
|
190
|
+
borderRadius: 12, border: 'none',
|
|
191
|
+
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
|
192
|
+
}}
|
|
193
|
+
styles={{body: {padding: 18}}}
|
|
194
|
+
>
|
|
195
|
+
<div style={{display: 'flex', alignItems: 'flex-start', gap: 12}}>
|
|
196
|
+
<div style={{
|
|
197
|
+
width: 48, height: 48, borderRadius: 10,
|
|
198
|
+
background: 'linear-gradient(135deg, #1890ff 0%, #36cfc9 100%)',
|
|
199
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
200
|
+
}}>
|
|
201
|
+
<FolderOutlined style={{fontSize: 22, color: '#fff'}}/>
|
|
202
|
+
</div>
|
|
203
|
+
<div style={{flex: 1, minWidth: 0}}>
|
|
204
|
+
<div style={{
|
|
205
|
+
fontSize: 15, fontWeight: 600, color: '#262626',
|
|
206
|
+
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
207
|
+
}}>
|
|
208
|
+
{bucket.name}
|
|
209
|
+
</div>
|
|
210
|
+
<div style={{fontSize: 12, color: '#999', marginTop: 4}}>
|
|
211
|
+
{bucket.region || 'default'} · {bucket.createTime?.slice(0, 10) || '-'}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
<Dropdown
|
|
215
|
+
trigger={['click']}
|
|
216
|
+
menu={aclMenu(bucket.name)}
|
|
217
|
+
onClick={e => e.stopPropagation()}
|
|
218
|
+
>
|
|
219
|
+
<Button
|
|
220
|
+
type="text" size="small" icon={<MoreOutlined/>}
|
|
221
|
+
onClick={e => e.stopPropagation()}
|
|
222
|
+
/>
|
|
223
|
+
</Dropdown>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div style={{marginTop: 14, display: 'flex', gap: 6, flexWrap: 'wrap'}}>
|
|
227
|
+
<Tag color={acl.color} icon={acl.icon}>{acl.label}</Tag>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div style={{
|
|
231
|
+
marginTop: 14, paddingTop: 12, borderTop: '1px solid #f5f5f5',
|
|
232
|
+
display: 'flex', gap: 8, justifyContent: 'space-between',
|
|
233
|
+
}}>
|
|
234
|
+
<Tooltip title="进入桶">
|
|
235
|
+
<Button
|
|
236
|
+
type="text" size="small" icon={<AppstoreOutlined/>}
|
|
237
|
+
onClick={e => {
|
|
238
|
+
e.stopPropagation()
|
|
239
|
+
navigate(`/ai/oss/bucket/${encodeURIComponent(bucket.name)}`)
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
浏览对象
|
|
243
|
+
</Button>
|
|
244
|
+
</Tooltip>
|
|
245
|
+
<Popconfirm
|
|
246
|
+
title={`确定删除桶 "${bucket.name}"?`}
|
|
247
|
+
description="桶必须为空才能删除"
|
|
248
|
+
onConfirm={e => {
|
|
249
|
+
e?.stopPropagation();
|
|
250
|
+
handleDelete(bucket.name)
|
|
251
|
+
}}
|
|
252
|
+
onCancel={e => e?.stopPropagation()}
|
|
253
|
+
okText="确认"
|
|
254
|
+
cancelText="取消"
|
|
255
|
+
okType="danger"
|
|
256
|
+
>
|
|
257
|
+
<Button
|
|
258
|
+
type="text" size="small" danger icon={<DeleteOutlined/>}
|
|
259
|
+
onClick={e => e.stopPropagation()}
|
|
260
|
+
>
|
|
261
|
+
删除
|
|
262
|
+
</Button>
|
|
263
|
+
</Popconfirm>
|
|
264
|
+
</div>
|
|
265
|
+
</Card>
|
|
266
|
+
</Col>
|
|
267
|
+
)
|
|
268
|
+
})}
|
|
269
|
+
</Row>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{/* 创建桶弹窗 */}
|
|
273
|
+
<Modal
|
|
274
|
+
title={
|
|
275
|
+
<Space>
|
|
276
|
+
<CloudOutlined style={{color: '#1890ff'}}/>
|
|
277
|
+
<span>创建存储桶</span>
|
|
278
|
+
</Space>
|
|
279
|
+
}
|
|
280
|
+
open={createOpen}
|
|
281
|
+
onCancel={() => {
|
|
282
|
+
setCreateOpen(false);
|
|
283
|
+
createForm.resetFields()
|
|
284
|
+
}}
|
|
285
|
+
onOk={handleCreate}
|
|
286
|
+
confirmLoading={creating}
|
|
287
|
+
okText="创建"
|
|
288
|
+
cancelText="取消"
|
|
289
|
+
destroyOnClose
|
|
290
|
+
>
|
|
291
|
+
<Form form={createForm} layout="vertical" initialValues={{acl: 'private'}} style={{marginTop: 16}}>
|
|
292
|
+
<Form.Item
|
|
293
|
+
name="name"
|
|
294
|
+
label="桶名称"
|
|
295
|
+
rules={[
|
|
296
|
+
{required: true, message: '请输入桶名称'},
|
|
297
|
+
{
|
|
298
|
+
pattern: /^[a-z0-9][a-z0-9-]{2,62}[a-z0-9]$/,
|
|
299
|
+
message: '只能包含小写字母、数字、连字符,长度 3-63'
|
|
300
|
+
},
|
|
301
|
+
]}
|
|
302
|
+
extra="仅支持小写字母、数字、连字符,长度 3-63 字符"
|
|
303
|
+
>
|
|
304
|
+
<Input placeholder="my-bucket" prefix={<FolderOutlined/>}/>
|
|
305
|
+
</Form.Item>
|
|
306
|
+
<Form.Item name="region" label="区域" initialValue="default">
|
|
307
|
+
<Input placeholder="default"/>
|
|
308
|
+
</Form.Item>
|
|
309
|
+
<Form.Item name="acl" label="访问权限" initialValue="private">
|
|
310
|
+
<Select
|
|
311
|
+
options={Object.entries(ACL_LABELS).map(([k, v]) => ({
|
|
312
|
+
value: k, label: <Space>{v.icon}{v.label}</Space>,
|
|
313
|
+
}))}
|
|
314
|
+
/>
|
|
315
|
+
</Form.Item>
|
|
316
|
+
</Form>
|
|
317
|
+
</Modal>
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|