Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
I
Internet-hospital
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Labels
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Analytics
Analytics
CI / CD
Repository
Value Stream
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Jobs
Commits
Open sidebar
yuguo
Internet-hospital
Commits
04584395
Commit
04584395
authored
Mar 04, 2026
by
yuguo
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
671cf8df
Changes
16
Show whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
2624 additions
and
828 deletions
+2624
-828
web/src/api/admin.ts
web/src/api/admin.ts
+2
-0
web/src/app/(main)/admin/agents/AllToolsTab.tsx
web/src/app/(main)/admin/agents/AllToolsTab.tsx
+169
-0
web/src/app/(main)/admin/agents/BuiltinToolsTab.tsx
web/src/app/(main)/admin/agents/BuiltinToolsTab.tsx
+144
-0
web/src/app/(main)/admin/agents/HTTPToolsTab.tsx
web/src/app/(main)/admin/agents/HTTPToolsTab.tsx
+238
-0
web/src/app/(main)/admin/agents/SkillsTab.tsx
web/src/app/(main)/admin/agents/SkillsTab.tsx
+207
-0
web/src/app/(main)/admin/agents/page.tsx
web/src/app/(main)/admin/agents/page.tsx
+228
-260
web/src/app/(main)/admin/agents/toolsConfig.ts
web/src/app/(main)/admin/agents/toolsConfig.ts
+30
-0
web/src/app/(main)/admin/ai-logs/page.tsx
web/src/app/(main)/admin/ai-logs/page.tsx
+513
-0
web/src/app/(main)/admin/layout.tsx
web/src/app/(main)/admin/layout.tsx
+74
-166
web/src/app/(main)/admin/menus/page.tsx
web/src/app/(main)/admin/menus/page.tsx
+260
-13
web/src/app/(main)/admin/workflows/page.tsx
web/src/app/(main)/admin/workflows/page.tsx
+2
-233
web/src/config/routes.ts
web/src/config/routes.ts
+30
-14
web/src/pages/admin/Departments/index.tsx
web/src/pages/admin/Departments/index.tsx
+171
-41
web/src/pages/admin/Doctors/index.tsx
web/src/pages/admin/Doctors/index.tsx
+34
-2
web/src/pages/admin/Workflows/index.tsx
web/src/pages/admin/Workflows/index.tsx
+259
-0
web/src/pages/patient/TextConsult/index.tsx
web/src/pages/patient/TextConsult/index.tsx
+263
-99
No files found.
web/src/api/admin.ts
View file @
04584395
...
...
@@ -47,6 +47,7 @@ export interface DoctorItem {
license_no
:
string
;
introduction
:
string
;
specialties
:
string
[];
price
:
number
;
review_status
:
string
;
review_id
:
string
;
user_status
:
string
;
...
...
@@ -290,6 +291,7 @@ export const adminApi = {
hospital
:
string
;
introduction
?:
string
;
specialties
?:
string
[];
price
?:
number
;
})
=>
post
<
null
>
(
'
/admin/doctors/create
'
,
data
),
updateDoctor
:
(
doctorId
:
string
,
data
:
UpdateDoctorData
)
=>
...
...
web/src/app/(main)/admin/agents/AllToolsTab.tsx
0 → 100644
View file @
04584395
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Tag
,
Switch
,
Badge
,
Empty
,
Tooltip
,
Typography
,
message
,
}
from
'
antd
'
;
import
{
useQuery
,
useMutation
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
{
agentApi
,
httpToolApi
}
from
'
@/api/agent
'
;
import
type
{
CATEGORY_CONFIG_TYPE
,
SOURCE_CONFIG_TYPE
}
from
'
./toolsConfig
'
;
const
{
Text
,
Paragraph
}
=
Typography
;
type
ToolSource
=
'
builtin
'
|
'
http
'
;
interface
UnifiedTool
{
id
:
string
;
name
:
string
;
description
:
string
;
category
:
string
;
source
:
ToolSource
;
is_enabled
:
boolean
;
cache_ttl
?:
number
;
timeout
?:
number
;
rawId
?:
number
;
}
interface
Props
{
search
:
string
;
categoryFilter
:
string
;
CATEGORY_CONFIG
:
CATEGORY_CONFIG_TYPE
;
SOURCE_CONFIG
:
SOURCE_CONFIG_TYPE
;
}
export
default
function
AllToolsTab
({
search
,
categoryFilter
,
CATEGORY_CONFIG
,
SOURCE_CONFIG
}:
Props
)
{
const
qc
=
useQueryClient
();
const
[
togglingName
,
setTogglingName
]
=
useState
(
''
);
const
{
data
:
builtinData
}
=
useQuery
({
queryKey
:
[
'
agent-tools
'
],
queryFn
:
()
=>
agentApi
.
listTools
(),
select
:
r
=>
(
r
.
data
??
[])
as
{
id
:
string
;
name
:
string
;
description
:
string
;
category
:
string
;
parameters
:
Record
<
string
,
unknown
>
;
is_enabled
:
boolean
;
created_at
:
string
;
}[],
});
const
{
data
:
httpData
}
=
useQuery
({
queryKey
:
[
'
http-tools
'
],
queryFn
:
()
=>
httpToolApi
.
list
(),
select
:
r
=>
r
.
data
??
[],
});
const
allTools
:
UnifiedTool
[]
=
[
...(
builtinData
??
[]).
map
(
t
=>
({
id
:
t
.
name
,
name
:
t
.
name
,
description
:
t
.
description
,
category
:
t
.
category
,
source
:
'
builtin
'
as
ToolSource
,
is_enabled
:
t
.
is_enabled
,
})),
...(
httpData
??
[]).
map
(
t
=>
({
id
:
`http:
${
t
.
id
}
`
,
name
:
t
.
name
,
description
:
t
.
description
||
t
.
display_name
,
category
:
t
.
category
||
'
http
'
,
source
:
'
http
'
as
ToolSource
,
is_enabled
:
t
.
status
===
'
active
'
,
cache_ttl
:
t
.
cache_ttl
,
timeout
:
t
.
timeout
,
rawId
:
t
.
id
,
})),
];
const
toggleMut
=
useMutation
({
mutationFn
:
async
({
tool
,
checked
}:
{
tool
:
UnifiedTool
;
checked
:
boolean
})
=>
{
if
(
tool
.
source
===
'
builtin
'
)
{
return
agentApi
.
updateToolStatus
(
tool
.
name
,
checked
?
'
active
'
:
'
disabled
'
);
}
else
{
return
httpToolApi
.
update
(
tool
.
rawId
!
,
{
status
:
checked
?
'
active
'
:
'
disabled
'
});
}
},
onSuccess
:
(
_
,
{
tool
,
checked
})
=>
{
message
.
success
(
`
${
tool
.
name
}
已
${
checked
?
'
启用
'
:
'
禁用
'
}
`
);
qc
.
invalidateQueries
({
queryKey
:
[
'
agent-tools
'
]
});
qc
.
invalidateQueries
({
queryKey
:
[
'
http-tools
'
]
});
setTogglingName
(
''
);
},
onError
:
()
=>
{
message
.
error
(
'
操作失败
'
);
setTogglingName
(
''
);
},
});
const
filtered
=
allTools
.
filter
(
t
=>
{
const
matchSearch
=
!
search
||
t
.
name
.
includes
(
search
)
||
t
.
description
.
includes
(
search
);
const
matchCategory
=
categoryFilter
===
'
all
'
||
t
.
category
===
categoryFilter
;
return
matchSearch
&&
matchCategory
;
});
const
grouped
:
Record
<
string
,
UnifiedTool
[]
>
=
{};
for
(
const
t
of
filtered
)
{
if
(
!
grouped
[
t
.
category
])
grouped
[
t
.
category
]
=
[];
grouped
[
t
.
category
].
push
(
t
);
}
if
(
Object
.
keys
(
grouped
).
length
===
0
)
{
return
<
Empty
description=
"暂无匹配工具"
style=
{
{
marginTop
:
60
}
}
/>;
}
return
(
<
div
style=
{
{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
20
}
}
>
{
Object
.
entries
(
grouped
).
map
(([
category
,
tools
])
=>
{
const
cfg
=
CATEGORY_CONFIG
[
category
]
||
CATEGORY_CONFIG
.
other
;
return
(
<
div
key=
{
category
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
8
,
marginBottom
:
12
}
}
>
<
Tag
color=
{
cfg
.
color
}
icon=
{
cfg
.
icon
}
style=
{
{
fontSize
:
13
,
padding
:
'
2px 10px
'
}
}
>
{
cfg
.
label
}
</
Tag
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
tools
.
length
}
个工具
</
Text
>
</
div
>
<
Row
gutter=
{
[
16
,
16
]
}
>
{
tools
.
map
(
tool
=>
(
<
Col
key=
{
tool
.
id
}
xs=
{
24
}
sm=
{
12
}
md=
{
8
}
lg=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderRadius
:
12
,
border
:
`1px solid ${tool.is_enabled ? '#d9f7be' : '#f0f0f0'}`
,
background
:
tool
.
is_enabled
?
'
#f6ffed
'
:
'
#fafafa
'
,
transition
:
'
all 0.2s
'
,
}
}
styles=
{
{
body
:
{
padding
:
14
}
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
space-between
'
,
alignItems
:
'
flex-start
'
,
marginBottom
:
8
}
}
>
<
div
style=
{
{
flex
:
1
,
minWidth
:
0
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
6
,
flexWrap
:
'
wrap
'
}
}
>
<
Badge
status=
{
tool
.
is_enabled
?
'
success
'
:
'
default
'
}
/>
<
Text
code
style=
{
{
fontSize
:
12
}
}
>
{
tool
.
name
}
</
Text
>
</
div
>
<
div
style=
{
{
marginTop
:
4
}
}
>
<
Tag
style=
{
{
fontSize
:
11
,
lineHeight
:
'
16px
'
,
padding
:
'
0 4px
'
,
margin
:
0
,
background
:
SOURCE_CONFIG
[
tool
.
source
].
color
+
'
15
'
,
color
:
SOURCE_CONFIG
[
tool
.
source
].
color
,
border
:
`1px solid ${SOURCE_CONFIG[tool.source].color}40`
,
}
}
>
{
SOURCE_CONFIG
[
tool
.
source
].
label
}
</
Tag
>
</
div
>
</
div
>
<
Tooltip
title=
{
tool
.
is_enabled
?
'
禁用工具
'
:
'
启用工具
'
}
>
<
Switch
size=
"small"
checked=
{
tool
.
is_enabled
}
loading=
{
togglingName
===
tool
.
id
}
onChange=
{
checked
=>
{
setTogglingName
(
tool
.
id
);
toggleMut
.
mutate
({
tool
,
checked
});
}
}
/>
</
Tooltip
>
</
div
>
<
Paragraph
ellipsis=
{
{
rows
:
2
}
}
style=
{
{
fontSize
:
12
,
color
:
'
#595959
'
,
margin
:
0
}
}
>
{
tool
.
description
}
</
Paragraph
>
{
(
tool
.
cache_ttl
!==
undefined
||
tool
.
timeout
!==
undefined
)
&&
(
<
div
style=
{
{
marginTop
:
8
,
display
:
'
flex
'
,
gap
:
8
}
}
>
{
tool
.
timeout
&&
<
Text
type=
"secondary"
style=
{
{
fontSize
:
11
}
}
>
超时
{
tool
.
timeout
}
s
</
Text
>
}
{
tool
.
cache_ttl
!==
undefined
&&
tool
.
cache_ttl
>
0
&&
<
Text
type=
"secondary"
style=
{
{
fontSize
:
11
}
}
>
缓存
{
tool
.
cache_ttl
}
s
</
Text
>
}
</
div
>
)
}
</
Card
>
</
Col
>
))
}
</
Row
>
</
div
>
);
})
}
</
div
>
);
}
web/src/app/(main)/admin/agents/BuiltinToolsTab.tsx
0 → 100644
View file @
04584395
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Button
,
Modal
,
Descriptions
,
Space
,
Badge
,
Typography
,
Switch
,
message
,
}
from
'
antd
'
;
import
{
InfoCircleOutlined
,
CodeOutlined
,
ReloadOutlined
}
from
'
@ant-design/icons
'
;
import
{
useQuery
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
{
agentApi
}
from
'
@/api/agent
'
;
import
type
{
CATEGORY_CONFIG_TYPE
}
from
'
./toolsConfig
'
;
const
{
Text
}
=
Typography
;
interface
AgentTool
{
id
:
string
;
name
:
string
;
display_name
:
string
;
description
:
string
;
category
:
string
;
parameters
:
Record
<
string
,
unknown
>
;
status
:
string
;
is_enabled
:
boolean
;
cache_ttl
:
number
;
timeout
:
number
;
max_retries
:
number
;
created_at
:
string
;
}
interface
Props
{
search
:
string
;
categoryFilter
:
string
;
CATEGORY_CONFIG
:
CATEGORY_CONFIG_TYPE
;
}
export
default
function
BuiltinToolsTab
({
search
,
categoryFilter
,
CATEGORY_CONFIG
}:
Props
)
{
const
qc
=
useQueryClient
();
const
[
togglingId
,
setTogglingId
]
=
useState
(
''
);
const
[
detailModal
,
setDetailModal
]
=
useState
<
{
open
:
boolean
;
tool
:
AgentTool
|
null
}
>
({
open
:
false
,
tool
:
null
});
const
{
data
:
tools
=
[],
isLoading
}
=
useQuery
({
queryKey
:
[
'
agent-tools
'
],
queryFn
:
()
=>
agentApi
.
listTools
(),
select
:
r
=>
(
r
.
data
??
[])
as
AgentTool
[],
});
const
handleToggle
=
async
(
tool
:
AgentTool
,
checked
:
boolean
)
=>
{
setTogglingId
(
tool
.
name
);
try
{
await
agentApi
.
updateToolStatus
(
tool
.
name
,
checked
?
'
active
'
:
'
disabled
'
);
message
.
success
(
`工具
${
tool
.
name
}
已
${
checked
?
'
启用
'
:
'
禁用
'
}
`
);
qc
.
invalidateQueries
({
queryKey
:
[
'
agent-tools
'
]
});
if
(
detailModal
.
tool
?.
name
===
tool
.
name
)
{
setDetailModal
(
prev
=>
({
...
prev
,
tool
:
prev
.
tool
?
{
...
prev
.
tool
,
is_enabled
:
checked
}
:
null
}));
}
}
catch
{
message
.
error
(
'
操作失败
'
);
}
finally
{
setTogglingId
(
''
);
}
};
const
filtered
=
tools
.
filter
(
t
=>
{
const
matchSearch
=
!
search
||
t
.
name
.
toLowerCase
().
includes
(
search
.
toLowerCase
())
||
t
.
description
.
toLowerCase
().
includes
(
search
.
toLowerCase
());
const
matchCategory
=
categoryFilter
===
'
all
'
||
t
.
category
===
categoryFilter
;
return
matchSearch
&&
matchCategory
;
});
const
columns
=
[
{
title
:
'
工具名称
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
,
render
:
(
v
:
string
)
=>
<
Space
><
CodeOutlined
style=
{
{
color
:
'
#0D9488
'
}
}
/><
Text
code
style=
{
{
fontSize
:
13
}
}
>
{
v
}
</
Text
></
Space
>,
},
{
title
:
'
描述
'
,
dataIndex
:
'
description
'
,
key
:
'
description
'
,
ellipsis
:
true
,
width
:
280
},
{
title
:
'
分类
'
,
dataIndex
:
'
category
'
,
key
:
'
category
'
,
width
:
110
,
render
:
(
v
:
string
)
=>
{
const
cfg
=
CATEGORY_CONFIG
[
v
]
||
CATEGORY_CONFIG
.
other
;
return
<
Tag
color=
{
cfg
.
color
}
>
{
cfg
.
label
}
</
Tag
>;
},
},
{
title
:
'
参数数量
'
,
dataIndex
:
'
parameters
'
,
key
:
'
parameters
'
,
width
:
90
,
render
:
(
v
:
Record
<
string
,
unknown
>
)
=>
<
Text
type=
"secondary"
>
{
v
?
Object
.
keys
(
v
).
length
:
0
}
个
</
Text
>,
},
{
title
:
'
超时/缓存
'
,
key
:
'
timing
'
,
width
:
110
,
render
:
(
_
:
unknown
,
r
:
AgentTool
)
=>
(
<
Space
direction=
"vertical"
size=
{
0
}
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
超时
{
r
.
timeout
||
30
}
s
</
Text
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
r
.
cache_ttl
>
0
?
`缓存 ${r.cache_ttl}s`
:
'
不缓存
'
}
</
Text
>
</
Space
>
),
},
{
title
:
'
启用状态
'
,
dataIndex
:
'
is_enabled
'
,
key
:
'
is_enabled
'
,
width
:
110
,
render
:
(
v
:
boolean
,
record
:
AgentTool
)
=>
(
<
Switch
checked=
{
v
}
loading=
{
togglingId
===
record
.
name
}
checkedChildren=
"启用"
unCheckedChildren=
"禁用"
onChange=
{
checked
=>
handleToggle
(
record
,
checked
)
}
/>
),
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
80
,
render
:
(
_
:
unknown
,
record
:
AgentTool
)
=>
(
<
Button
type=
"link"
size=
"small"
icon=
{
<
InfoCircleOutlined
/>
}
onClick=
{
()
=>
setDetailModal
({
open
:
true
,
tool
:
record
})
}
>
详情
</
Button
>
),
},
];
return
(
<>
<
Card
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Table
dataSource=
{
filtered
}
columns=
{
columns
}
rowKey=
"name"
loading=
{
isLoading
}
size=
"small"
pagination=
{
{
pageSize
:
10
,
showSizeChanger
:
true
,
size
:
'
small
'
,
showTotal
:
t
=>
`共 ${t} 个工具`
}
}
/>
</
Card
>
<
Modal
title=
{
<
Space
><
CodeOutlined
style=
{
{
color
:
'
#0D9488
'
}
}
/><
span
>
工具详情
</
span
></
Space
>
}
open=
{
detailModal
.
open
}
onCancel=
{
()
=>
setDetailModal
({
open
:
false
,
tool
:
null
})
}
footer=
{
detailModal
.
tool
&&
(
<
Space
>
<
Switch
checked=
{
detailModal
.
tool
.
is_enabled
}
loading=
{
togglingId
===
detailModal
.
tool
.
name
}
checkedChildren=
"启用"
unCheckedChildren=
"禁用"
onChange=
{
checked
=>
handleToggle
(
detailModal
.
tool
!
,
checked
)
}
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
detailModal
.
tool
.
is_enabled
?
'
工具已启用,Agent 可正常调用
'
:
'
工具已禁用
'
}
</
Text
>
</
Space
>
)
}
width=
{
600
}
>
{
detailModal
.
tool
&&
(
<
div
style=
{
{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
16
}
}
>
<
Descriptions
column=
{
1
}
bordered
size=
"small"
>
<
Descriptions
.
Item
label=
"工具名称"
><
Text
code
style=
{
{
color
:
'
#0D9488
'
}
}
>
{
detailModal
.
tool
.
name
}
</
Text
></
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"描述"
>
{
detailModal
.
tool
.
description
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"分类"
><
Tag
color=
{
(
CATEGORY_CONFIG
[
detailModal
.
tool
.
category
]
||
CATEGORY_CONFIG
.
other
).
color
}
>
{
(
CATEGORY_CONFIG
[
detailModal
.
tool
.
category
]
||
CATEGORY_CONFIG
.
other
).
label
}
</
Tag
></
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"状态"
><
Badge
status=
{
detailModal
.
tool
.
is_enabled
?
'
success
'
:
'
default
'
}
text=
{
detailModal
.
tool
.
is_enabled
?
'
已启用
'
:
'
已禁用
'
}
/></
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"执行超时"
>
{
detailModal
.
tool
.
timeout
||
30
}
秒
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"结果缓存"
>
{
detailModal
.
tool
.
cache_ttl
>
0
?
`${detailModal.tool.cache_ttl} 秒`
:
'
不缓存
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"失败重试"
>
{
detailModal
.
tool
.
max_retries
||
0
}
次
</
Descriptions
.
Item
>
</
Descriptions
>
<
Card
size=
"small"
title=
"参数定义"
style=
{
{
background
:
'
#fafafa
'
,
borderRadius
:
8
}
}
>
<
pre
style=
{
{
fontSize
:
12
,
background
:
'
#1f2937
'
,
color
:
'
#4ade80
'
,
padding
:
12
,
borderRadius
:
6
,
overflow
:
'
auto
'
,
margin
:
0
}
}
>
{
JSON
.
stringify
(
detailModal
.
tool
.
parameters
,
null
,
2
)
}
</
pre
>
</
Card
>
</
div
>
)
}
</
Modal
>
</>
);
}
web/src/app/(main)/admin/agents/HTTPToolsTab.tsx
0 → 100644
View file @
04584395
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Button
,
Modal
,
Form
,
Input
,
Select
,
InputNumber
,
Space
,
Popconfirm
,
message
,
Typography
,
Badge
,
Tooltip
,
}
from
'
antd
'
;
import
{
PlusOutlined
,
EditOutlined
,
DeleteOutlined
,
ApiOutlined
,
ThunderboltOutlined
,
ReloadOutlined
,
PlayCircleOutlined
,
CodeOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useQuery
,
useMutation
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
{
httpToolApi
,
type
HTTPToolDefinition
}
from
'
@/api/agent
'
;
const
{
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
const
METHOD_COLORS
:
Record
<
string
,
string
>
=
{
GET
:
'
green
'
,
POST
:
'
blue
'
,
PUT
:
'
orange
'
,
DELETE
:
'
red
'
,
PATCH
:
'
purple
'
,
};
const
AUTH_LABELS
:
Record
<
string
,
string
>
=
{
none
:
'
无认证
'
,
bearer
:
'
Bearer Token
'
,
basic
:
'
Basic Auth
'
,
apikey
:
'
API Key
'
,
};
interface
Props
{
search
:
string
}
export
default
function
HTTPToolsTab
({
search
}:
Props
)
{
const
qc
=
useQueryClient
();
const
[
form
]
=
Form
.
useForm
();
const
[
testForm
]
=
Form
.
useForm
();
const
[
modalOpen
,
setModalOpen
]
=
useState
(
false
);
const
[
testModal
,
setTestModal
]
=
useState
<
{
open
:
boolean
;
tool
:
HTTPToolDefinition
|
null
}
>
({
open
:
false
,
tool
:
null
});
const
[
editingId
,
setEditingId
]
=
useState
<
number
|
null
>
(
null
);
const
[
authType
,
setAuthType
]
=
useState
(
'
none
'
);
const
{
data
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
http-tools
'
],
queryFn
:
()
=>
httpToolApi
.
list
(),
select
:
r
=>
r
.
data
??
[],
});
const
tools
=
(
data
??
[]).
filter
(
t
=>
!
search
||
t
.
name
.
includes
(
search
)
||
(
t
.
description
||
''
).
includes
(
search
)
);
const
saveMut
=
useMutation
({
mutationFn
:
(
values
:
Record
<
string
,
unknown
>
)
=>
editingId
?
httpToolApi
.
update
(
editingId
,
values
)
:
httpToolApi
.
create
(
values
),
onSuccess
:
()
=>
{
message
.
success
(
editingId
?
'
更新成功
'
:
'
创建成功
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
http-tools
'
]
});
setModalOpen
(
false
);
form
.
resetFields
();
setEditingId
(
null
);
},
onError
:
()
=>
message
.
error
(
'
操作失败
'
),
});
const
deleteMut
=
useMutation
({
mutationFn
:
(
id
:
number
)
=>
httpToolApi
.
delete
(
id
),
onSuccess
:
()
=>
{
message
.
success
(
'
已删除
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
http-tools
'
]
});
},
});
const
reloadMut
=
useMutation
({
mutationFn
:
()
=>
httpToolApi
.
reload
(),
onSuccess
:
()
=>
message
.
success
(
'
HTTP 工具已热重载
'
),
});
const
testMut
=
useMutation
({
mutationFn
:
({
id
,
params
}:
{
id
:
number
;
params
:
Record
<
string
,
unknown
>
})
=>
httpToolApi
.
test
(
id
,
params
),
});
const
openCreate
=
()
=>
{
setEditingId
(
null
);
form
.
resetFields
();
setAuthType
(
'
none
'
);
setModalOpen
(
true
);
};
const
openEdit
=
(
tool
:
HTTPToolDefinition
)
=>
{
setEditingId
(
tool
.
id
);
setAuthType
(
tool
.
auth_type
||
'
none
'
);
let
authConfig
:
Record
<
string
,
string
>
=
{};
try
{
authConfig
=
JSON
.
parse
(
tool
.
auth_config
||
'
{}
'
);
}
catch
{
/* ignore */
}
form
.
setFieldsValue
({
name
:
tool
.
name
,
display_name
:
tool
.
display_name
,
description
:
tool
.
description
,
category
:
tool
.
category
,
method
:
tool
.
method
||
'
GET
'
,
url
:
tool
.
url
,
headers
:
tool
.
headers
,
body_template
:
tool
.
body_template
,
auth_type
:
tool
.
auth_type
||
'
none
'
,
timeout
:
tool
.
timeout
||
10
,
cache_ttl
:
tool
.
cache_ttl
||
0
,
status
:
tool
.
status
,
...
authConfig
,
});
setModalOpen
(
true
);
};
const
handleSave
=
()
=>
{
form
.
validateFields
().
then
(
values
=>
{
const
authConfig
:
Record
<
string
,
string
>
=
{};
if
(
values
.
auth_type
===
'
bearer
'
)
authConfig
.
token
=
values
.
token
||
''
;
else
if
(
values
.
auth_type
===
'
basic
'
)
{
authConfig
.
username
=
values
.
username
||
''
;
authConfig
.
password
=
values
.
password
||
''
;
}
else
if
(
values
.
auth_type
===
'
apikey
'
)
{
authConfig
.
key
=
values
.
key
||
''
;
authConfig
.
value
=
values
.
value
||
''
;
authConfig
.
in
=
values
.
in
||
'
header
'
;
}
saveMut
.
mutate
({
name
:
values
.
name
,
display_name
:
values
.
display_name
||
values
.
name
,
description
:
values
.
description
||
''
,
category
:
values
.
category
||
'
http
'
,
method
:
values
.
method
||
'
GET
'
,
url
:
values
.
url
,
headers
:
values
.
headers
||
'
{}
'
,
body_template
:
values
.
body_template
||
''
,
auth_type
:
values
.
auth_type
||
'
none
'
,
auth_config
:
JSON
.
stringify
(
authConfig
),
parameters
:
'
[]
'
,
timeout
:
values
.
timeout
||
10
,
cache_ttl
:
values
.
cache_ttl
||
0
,
status
:
values
.
status
||
'
active
'
,
});
});
};
const
handleTest
=
()
=>
{
if
(
!
testModal
.
tool
)
return
;
testForm
.
validateFields
().
then
(
values
=>
{
let
params
:
Record
<
string
,
unknown
>
=
{};
try
{
params
=
JSON
.
parse
(
values
.
params
||
'
{}
'
);
}
catch
{
message
.
error
(
'
参数格式错误
'
);
return
;
}
testMut
.
mutate
({
id
:
testModal
.
tool
!
.
id
,
params
});
});
};
const
columns
=
[
{
title
:
'
名称
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
,
render
:
(
v
:
string
,
r
:
HTTPToolDefinition
)
=>
(
<
div
>
<
Text
code
style=
{
{
fontSize
:
13
}
}
>
{
v
}
</
Text
>
{
r
.
display_name
&&
r
.
display_name
!==
v
&&
<
div
style=
{
{
fontSize
:
12
,
color
:
'
#8c8c8c
'
}
}
>
{
r
.
display_name
}
</
div
>
}
</
div
>
),
},
{
title
:
'
描述
'
,
dataIndex
:
'
description
'
,
key
:
'
description
'
,
ellipsis
:
true
,
width
:
200
},
{
title
:
'
方法
'
,
dataIndex
:
'
method
'
,
key
:
'
method
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
<
Tag
color=
{
METHOD_COLORS
[
v
]
||
'
default
'
}
>
{
v
}
</
Tag
>
},
{
title
:
'
URL
'
,
dataIndex
:
'
url
'
,
key
:
'
url
'
,
ellipsis
:
true
,
width
:
200
,
render
:
(
v
:
string
)
=>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
v
}
</
Text
>
},
{
title
:
'
认证
'
,
dataIndex
:
'
auth_type
'
,
key
:
'
auth_type
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
<
Tag
>
{
AUTH_LABELS
[
v
]
||
v
}
</
Tag
>
},
{
title
:
'
超时/缓存
'
,
key
:
'
timing
'
,
width
:
110
,
render
:
(
_
:
unknown
,
r
:
HTTPToolDefinition
)
=>
(
<
Space
direction=
"vertical"
size=
{
0
}
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
11
}
}
>
超时:
{
r
.
timeout
}
s
</
Text
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
11
}
}
>
缓存:
{
r
.
cache_ttl
>
0
?
`${r.cache_ttl}s`
:
'
不缓存
'
}
</
Text
>
</
Space
>
),
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
<
Badge
status=
{
v
===
'
active
'
?
'
success
'
:
'
default
'
}
text=
{
v
===
'
active
'
?
'
启用
'
:
'
禁用
'
}
/>,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
160
,
render
:
(
_
:
unknown
,
record
:
HTTPToolDefinition
)
=>
(
<
Space
>
<
Tooltip
title=
"测试"
><
Button
type=
"link"
size=
"small"
icon=
{
<
PlayCircleOutlined
/>
}
onClick=
{
()
=>
{
setTestModal
({
open
:
true
,
tool
:
record
});
testForm
.
resetFields
();
testMut
.
reset
();
}
}
/></
Tooltip
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
openEdit
(
record
)
}
/>
<
Popconfirm
title=
"确定删除?"
onConfirm=
{
()
=>
deleteMut
.
mutate
(
record
.
id
)
}
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
/>
</
Popconfirm
>
</
Space
>
),
},
];
return
(
<>
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
flex-end
'
,
marginBottom
:
12
}
}
>
<
Space
>
<
Button
icon=
{
<
ReloadOutlined
/>
}
onClick=
{
()
=>
reloadMut
.
mutate
()
}
loading=
{
reloadMut
.
isPending
}
>
热重载
</
Button
>
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
openCreate
}
>
新建 HTTP 工具
</
Button
>
</
Space
>
</
div
>
<
Card
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Table
dataSource=
{
tools
}
columns=
{
columns
}
rowKey=
"id"
loading=
{
isLoading
}
size=
"small"
pagination=
{
{
pageSize
:
10
,
showTotal
:
t
=>
`共 ${t} 个工具`
}
}
/>
</
Card
>
{
/* Create/Edit Modal */
}
<
Modal
title=
{
<
Space
><
ApiOutlined
style=
{
{
color
:
'
#0D9488
'
}
}
/>
{
editingId
?
'
编辑 HTTP 工具
'
:
'
新建 HTTP 工具
'
}
</
Space
>
}
open=
{
modalOpen
}
onCancel=
{
()
=>
{
setModalOpen
(
false
);
form
.
resetFields
();
}
}
onOk=
{
handleSave
}
confirmLoading=
{
saveMut
.
isPending
}
width=
{
680
}
destroyOnHidden
>
<
Form
form=
{
form
}
layout=
"vertical"
style=
{
{
marginTop
:
12
}
}
>
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
1fr 1fr
'
,
gap
:
'
0 16px
'
}
}
>
<
Form
.
Item
name=
"name"
label=
"工具名称(英文)"
rules=
{
[{
required
:
true
,
message
:
'
必填
'
}]
}
>
<
Input
placeholder=
"my_http_tool"
disabled=
{
!!
editingId
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"display_name"
label=
"显示名称"
><
Input
placeholder=
"我的 HTTP 工具"
/></
Form
.
Item
>
</
div
>
<
Form
.
Item
name=
"description"
label=
"描述"
><
TextArea
rows=
{
2
}
placeholder=
"工具功能说明"
/></
Form
.
Item
>
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
120px 1fr
'
,
gap
:
'
0 12px
'
}
}
>
<
Form
.
Item
name=
"method"
label=
"请求方法"
initialValue=
"GET"
>
<
Select
options=
{
[
'
GET
'
,
'
POST
'
,
'
PUT
'
,
'
DELETE
'
,
'
PATCH
'
].
map
(
m
=>
({
value
:
m
,
label
:
m
}))
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"url"
label=
"URL"
rules=
{
[{
required
:
true
,
message
:
'
必填
'
}]
}
>
<
Input
placeholder=
"https://api.example.com/endpoint?q={{keyword}}"
/>
</
Form
.
Item
>
</
div
>
<
Form
.
Item
name=
"headers"
label=
"请求头 (JSON)"
initialValue=
"{}"
><
TextArea
rows=
{
2
}
/></
Form
.
Item
>
<
Form
.
Item
name=
"body_template"
label=
"请求体模板(支持 {{variable}})"
><
TextArea
rows=
{
3
}
/></
Form
.
Item
>
<
Form
.
Item
name=
"auth_type"
label=
"认证方式"
initialValue=
"none"
>
<
Select
options=
{
Object
.
entries
(
AUTH_LABELS
).
map
(([
v
,
l
])
=>
({
value
:
v
,
label
:
l
}))
}
onChange=
{
v
=>
setAuthType
(
v
)
}
/>
</
Form
.
Item
>
{
authType
===
'
bearer
'
&&
<
Form
.
Item
name=
"token"
label=
"Bearer Token"
rules=
{
[{
required
:
true
}]
}
><
Input
.
Password
/></
Form
.
Item
>
}
{
authType
===
'
basic
'
&&
(
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
1fr 1fr
'
,
gap
:
'
0 16px
'
}
}
>
<
Form
.
Item
name=
"username"
label=
"用户名"
rules=
{
[{
required
:
true
}]
}
><
Input
/></
Form
.
Item
>
<
Form
.
Item
name=
"password"
label=
"密码"
rules=
{
[{
required
:
true
}]
}
><
Input
.
Password
/></
Form
.
Item
>
</
div
>
)
}
{
authType
===
'
apikey
'
&&
(
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
1fr 1fr 120px
'
,
gap
:
'
0 12px
'
}
}
>
<
Form
.
Item
name=
"key"
label=
"参数名"
rules=
{
[{
required
:
true
}]
}
><
Input
/></
Form
.
Item
>
<
Form
.
Item
name=
"value"
label=
"参数值"
rules=
{
[{
required
:
true
}]
}
><
Input
.
Password
/></
Form
.
Item
>
<
Form
.
Item
name=
"in"
label=
"位置"
initialValue=
"header"
>
<
Select
options=
{
[{
value
:
'
header
'
,
label
:
'
Header
'
},
{
value
:
'
query
'
,
label
:
'
Query
'
}]
}
/>
</
Form
.
Item
>
</
div
>
)
}
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
1fr 1fr 1fr
'
,
gap
:
'
0 16px
'
}
}
>
<
Form
.
Item
name=
"timeout"
label=
"超时(秒)"
initialValue=
{
10
}
><
InputNumber
min=
{
1
}
max=
{
120
}
style=
{
{
width
:
'
100%
'
}
}
/></
Form
.
Item
>
<
Form
.
Item
name=
"cache_ttl"
label=
"缓存TTL(秒)"
initialValue=
{
0
}
><
InputNumber
min=
{
0
}
max=
{
86400
}
style=
{
{
width
:
'
100%
'
}
}
/></
Form
.
Item
>
<
Form
.
Item
name=
"status"
label=
"状态"
initialValue=
"active"
>
<
Select
options=
{
[{
value
:
'
active
'
,
label
:
'
启用
'
},
{
value
:
'
disabled
'
,
label
:
'
禁用
'
}]
}
/>
</
Form
.
Item
>
</
div
>
</
Form
>
</
Modal
>
{
/* Test Modal */
}
<
Modal
title=
{
<
Space
><
ThunderboltOutlined
style=
{
{
color
:
'
#fa8c16
'
}
}
/>
测试工具:
<
Text
code
>
{
testModal
.
tool
?.
name
}
</
Text
></
Space
>
}
open=
{
testModal
.
open
}
onCancel=
{
()
=>
setTestModal
({
open
:
false
,
tool
:
null
})
}
onOk=
{
handleTest
}
confirmLoading=
{
testMut
.
isPending
}
width=
{
600
}
>
<
Form
form=
{
testForm
}
layout=
"vertical"
>
<
Form
.
Item
name=
"params"
label=
"输入参数 (JSON)"
><
TextArea
rows=
{
4
}
placeholder=
'{"keyword": "高血压"}'
/></
Form
.
Item
>
</
Form
>
{
testMut
.
data
&&
(
<
Card
size=
"small"
title=
"调用结果"
style=
{
{
background
:
'
#fafafa
'
}
}
>
<
pre
style=
{
{
fontSize
:
12
,
background
:
'
#1f2937
'
,
color
:
testMut
.
data
.
data
?.
success
?
'
#4ade80
'
:
'
#f87171
'
,
padding
:
12
,
borderRadius
:
6
,
overflow
:
'
auto
'
,
margin
:
0
}
}
>
{
JSON
.
stringify
(
testMut
.
data
.
data
,
null
,
2
)
}
</
pre
>
</
Card
>
)
}
</
Modal
>
</>
);
}
web/src/app/(main)/admin/agents/SkillsTab.tsx
0 → 100644
View file @
04584395
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Tag
,
Button
,
Modal
,
Form
,
Input
,
Select
,
Empty
,
Space
,
Popconfirm
,
message
,
Typography
,
Badge
,
}
from
'
antd
'
;
import
{
PlusOutlined
,
EditOutlined
,
DeleteOutlined
,
ThunderboltOutlined
,
BookOutlined
,
HeartOutlined
,
SafetyOutlined
,
CalendarOutlined
,
MedicineBoxOutlined
,
SettingOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useQuery
,
useMutation
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
{
skillApi
,
agentApi
,
type
AgentSkill
}
from
'
@/api/agent
'
;
const
{
Text
,
Paragraph
}
=
Typography
;
const
{
TextArea
}
=
Input
;
const
SKILL_ICONS
:
Record
<
string
,
React
.
ReactNode
>
=
{
heart
:
<
HeartOutlined
/>,
book
:
<
BookOutlined
/>,
safety
:
<
SafetyOutlined
/>,
calendar
:
<
CalendarOutlined
/>,
'
medicine-box
'
:
<
MedicineBoxOutlined
/>
,
'
file-text
'
:
<
BookOutlined
/>
,
setting
:
<
SettingOutlined
/>,
};
const
CATEGORY_COLORS
:
Record
<
string
,
string
>
=
{
patient
:
'
green
'
,
doctor
:
'
blue
'
,
admin
:
'
purple
'
,
general
:
'
cyan
'
,
};
const
CATEGORY_LABELS
:
Record
<
string
,
string
>
=
{
patient
:
'
患者
'
,
doctor
:
'
医生
'
,
admin
:
'
管理
'
,
general
:
'
通用
'
,
};
interface
Props
{
search
:
string
}
export
default
function
SkillsTab
({
search
}:
Props
)
{
const
qc
=
useQueryClient
();
const
[
form
]
=
Form
.
useForm
();
const
[
modalOpen
,
setModalOpen
]
=
useState
(
false
);
const
[
editingSkill
,
setEditingSkill
]
=
useState
<
AgentSkill
|
null
>
(
null
);
const
{
data
:
skills
=
[]
}
=
useQuery
({
queryKey
:
[
'
agent-skills
'
],
queryFn
:
()
=>
skillApi
.
list
(),
select
:
r
=>
r
.
data
??
[],
});
const
{
data
:
toolList
=
[]
}
=
useQuery
({
queryKey
:
[
'
agent-tools
'
],
queryFn
:
()
=>
agentApi
.
listTools
(),
select
:
r
=>
(
r
.
data
??
[]).
map
((
t
:
{
name
:
string
;
description
:
string
})
=>
({
value
:
t
.
name
,
label
:
`
${
t
.
name
}
-
${
t
.
description
}
`
})),
});
const
saveMut
=
useMutation
({
mutationFn
:
(
values
:
Record
<
string
,
unknown
>
)
=>
editingSkill
?
skillApi
.
update
(
editingSkill
.
skill_id
,
values
as
Partial
<
AgentSkill
>
)
:
skillApi
.
create
(
values
as
Partial
<
AgentSkill
>
),
onSuccess
:
()
=>
{
message
.
success
(
editingSkill
?
'
更新成功
'
:
'
创建成功
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
agent-skills
'
]
});
setModalOpen
(
false
);
form
.
resetFields
();
setEditingSkill
(
null
);
},
onError
:
()
=>
message
.
error
(
'
操作失败
'
),
});
const
deleteMut
=
useMutation
({
mutationFn
:
(
skillId
:
string
)
=>
skillApi
.
delete
(
skillId
),
onSuccess
:
()
=>
{
message
.
success
(
'
已删除
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
agent-skills
'
]
});
},
});
const
openCreate
=
()
=>
{
setEditingSkill
(
null
);
form
.
resetFields
();
setModalOpen
(
true
);
};
const
openEdit
=
(
skill
:
AgentSkill
)
=>
{
setEditingSkill
(
skill
);
let
parsedTools
:
string
[]
=
[];
let
parsedQR
:
string
[]
=
[];
try
{
parsedTools
=
JSON
.
parse
(
skill
.
tools
||
'
[]
'
);
}
catch
{
/* */
}
try
{
parsedQR
=
JSON
.
parse
(
skill
.
quick_replies
||
'
[]
'
);
}
catch
{
/* */
}
form
.
setFieldsValue
({
skill_id
:
skill
.
skill_id
,
name
:
skill
.
name
,
description
:
skill
.
description
,
category
:
skill
.
category
,
tools
:
parsedTools
,
system_prompt_addon
:
skill
.
system_prompt_addon
,
quick_replies
:
parsedQR
.
join
(
'
\n
'
),
icon
:
skill
.
icon
,
});
setModalOpen
(
true
);
};
const
handleSave
=
()
=>
{
form
.
validateFields
().
then
(
values
=>
{
const
quickReplies
=
(
values
.
quick_replies
||
''
).
split
(
'
\n
'
).
filter
((
s
:
string
)
=>
s
.
trim
());
saveMut
.
mutate
({
skill_id
:
values
.
skill_id
,
name
:
values
.
name
,
description
:
values
.
description
||
''
,
category
:
values
.
category
||
'
general
'
,
tools
:
values
.
tools
||
[],
system_prompt_addon
:
values
.
system_prompt_addon
||
''
,
quick_replies
:
quickReplies
,
icon
:
values
.
icon
||
'
setting
'
,
});
});
};
const
filtered
=
skills
.
filter
(
s
=>
!
search
||
s
.
name
.
includes
(
search
)
||
s
.
skill_id
.
includes
(
search
)
||
(
s
.
description
||
''
).
includes
(
search
)
);
return
(
<>
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
flex-end
'
,
marginBottom
:
12
}
}
>
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
openCreate
}
>
新建技能包
</
Button
>
</
div
>
{
filtered
.
length
===
0
?
(
<
Empty
description=
"暂无技能包"
style=
{
{
marginTop
:
40
}
}
/>
)
:
(
<
Row
gutter=
{
[
16
,
16
]
}
>
{
filtered
.
map
(
skill
=>
{
let
toolCount
=
0
;
try
{
toolCount
=
JSON
.
parse
(
skill
.
tools
||
'
[]
'
).
length
;
}
catch
{
/* */
}
const
icon
=
SKILL_ICONS
[
skill
.
icon
]
||
<
ThunderboltOutlined
/>;
return
(
<
Col
key=
{
skill
.
skill_id
}
xs=
{
24
}
sm=
{
12
}
md=
{
8
}
lg=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
,
transition
:
'
all 0.2s
'
}
}
styles=
{
{
body
:
{
padding
:
16
}
}
}
hoverable
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
flex-start
'
,
gap
:
12
,
marginBottom
:
10
}
}
>
<
div
style=
{
{
width
:
40
,
height
:
40
,
borderRadius
:
10
,
background
:
'
linear-gradient(135deg, #667eea, #764ba2)
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
color
:
'
#fff
'
,
fontSize
:
18
,
flexShrink
:
0
,
}
}
>
{
icon
}
</
div
>
<
div
style=
{
{
flex
:
1
,
minWidth
:
0
}
}
>
<
div
style=
{
{
fontWeight
:
600
,
fontSize
:
14
,
color
:
'
#1d2129
'
}
}
>
{
skill
.
name
}
</
div
>
<
div
style=
{
{
display
:
'
flex
'
,
gap
:
4
,
marginTop
:
4
}
}
>
<
Tag
color=
{
CATEGORY_COLORS
[
skill
.
category
]
||
'
default
'
}
style=
{
{
fontSize
:
11
,
margin
:
0
}
}
>
{
CATEGORY_LABELS
[
skill
.
category
]
||
skill
.
category
}
</
Tag
>
<
Tag
style=
{
{
fontSize
:
11
,
margin
:
0
}
}
>
{
toolCount
}
个工具
</
Tag
>
</
div
>
</
div
>
</
div
>
<
Paragraph
ellipsis=
{
{
rows
:
2
}
}
style=
{
{
fontSize
:
12
,
color
:
'
#595959
'
,
margin
:
'
0 0 10px
'
}
}
>
{
skill
.
description
||
'
暂无描述
'
}
</
Paragraph
>
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
flex-end
'
,
gap
:
4
}
}
>
<
Button
type=
"text"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
openEdit
(
skill
)
}
/>
<
Popconfirm
title=
"确定删除该技能包?"
onConfirm=
{
()
=>
deleteMut
.
mutate
(
skill
.
skill_id
)
}
>
<
Button
type=
"text"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
/>
</
Popconfirm
>
</
div
>
</
Card
>
</
Col
>
);
})
}
</
Row
>
)
}
{
/* Create/Edit Modal */
}
<
Modal
title=
{
editingSkill
?
'
编辑技能包
'
:
'
新建技能包
'
}
open=
{
modalOpen
}
onCancel=
{
()
=>
{
setModalOpen
(
false
);
form
.
resetFields
();
setEditingSkill
(
null
);
}
}
onOk=
{
handleSave
}
confirmLoading=
{
saveMut
.
isPending
}
width=
{
640
}
destroyOnHidden
>
<
Form
form=
{
form
}
layout=
"vertical"
style=
{
{
marginTop
:
12
}
}
>
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
1fr 1fr
'
,
gap
:
'
0 16px
'
}
}
>
<
Form
.
Item
name=
"skill_id"
label=
"技能ID(英文)"
rules=
{
[{
required
:
true
,
message
:
'
必填
'
}]
}
>
<
Input
placeholder=
"symptom_analysis"
disabled=
{
!!
editingSkill
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"name"
label=
"技能名称"
rules=
{
[{
required
:
true
,
message
:
'
必填
'
}]
}
>
<
Input
placeholder=
"症状分析"
/>
</
Form
.
Item
>
</
div
>
<
Form
.
Item
name=
"description"
label=
"描述"
>
<
TextArea
rows=
{
2
}
placeholder=
"技能功能说明"
/>
</
Form
.
Item
>
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
1fr 1fr
'
,
gap
:
'
0 16px
'
}
}
>
<
Form
.
Item
name=
"category"
label=
"分类"
initialValue=
"general"
>
<
Select
options=
{
Object
.
entries
(
CATEGORY_LABELS
).
map
(([
v
,
l
])
=>
({
value
:
v
,
label
:
l
}))
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"icon"
label=
"图标"
initialValue=
"setting"
>
<
Select
options=
{
Object
.
keys
(
SKILL_ICONS
).
map
(
k
=>
({
value
:
k
,
label
:
k
}))
}
/>
</
Form
.
Item
>
</
div
>
<
Form
.
Item
name=
"tools"
label=
"关联工具"
>
<
Select
mode=
"multiple"
placeholder=
"选择工具"
options=
{
toolList
}
allowClear
filterOption=
{
(
input
,
option
)
=>
(
option
?.
label
??
''
).
toString
().
toLowerCase
().
includes
(
input
.
toLowerCase
())
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"system_prompt_addon"
label=
"系统提示词追加"
>
<
TextArea
rows=
{
3
}
placeholder=
"加载此技能时追加到Agent系统提示的内容"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"quick_replies"
label=
"快捷回复(每行一个)"
>
<
TextArea
rows=
{
3
}
placeholder=
{
"
头痛
\n
发热
\n
咳嗽
"
}
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
</>
);
}
web/src/app/(main)/admin/agents/page.tsx
View file @
04584395
'
use client
'
;
import
{
useState
,
useEffect
}
from
'
react
'
;
import
{
useState
,
useEffect
,
useMemo
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Button
,
Drawer
,
Input
,
message
,
Space
,
Collapse
,
Timeline
,
Typography
,
Tabs
,
DatePicker
,
Select
,
Badge
,
Tooltip
,
Form
,
InputNumber
,
Switch
,
Card
,
Table
,
Tag
,
Button
,
Modal
,
Input
,
message
,
Space
,
Collapse
,
Timeline
,
Typography
,
Tabs
,
Select
,
Badge
,
Tooltip
,
Form
,
InputNumber
,
Switch
,
Segmented
,
}
from
'
antd
'
;
import
{
DrawerForm
}
from
'
@ant-design/pro-components
'
;
import
{
RobotOutlined
,
PlayCircleOutlined
,
ToolOutlined
,
CheckCircleOutlined
,
CloseCircleOutlined
,
HistoryOutlined
,
ThunderboltOutlined
,
EditOutlined
,
ReloadOutlined
,
PlusOutlined
,
CloseCircleOutlined
,
ThunderboltOutlined
,
EditOutlined
,
ReloadOutlined
,
PlusOutlined
,
SearchOutlined
,
AppstoreOutlined
,
CloudOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useQuery
,
useMutation
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
{
agentApi
,
skillApi
}
from
'
@/api/agent
'
;
import
type
{
ToolCall
,
AgentExecutionLog
,
AgentDefinition
}
from
'
@/api/agent
'
;
import
{
agentApi
,
skillApi
,
httpToolApi
}
from
'
@/api/agent
'
;
import
type
{
ToolCall
,
AgentDefinition
}
from
'
@/api/agent
'
;
import
AllToolsTab
from
'
./AllToolsTab
'
;
import
BuiltinToolsTab
from
'
./BuiltinToolsTab
'
;
import
HTTPToolsTab
from
'
./HTTPToolsTab
'
;
import
SkillsTab
from
'
./SkillsTab
'
;
import
{
CATEGORY_CONFIG
,
SOURCE_CONFIG
}
from
'
./toolsConfig
'
;
const
{
Text
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
const
categoryColor
:
Record
<
string
,
string
>
=
{
patient
:
'
green
'
,
doctor
:
'
blue
'
,
pharmacy
:
'
orange
'
,
admin
:
'
purple
'
,
general
:
'
cyan
'
,
...
...
@@ -59,57 +64,33 @@ interface AgentResponse {
total_tokens
?:
number
;
}
export
default
function
AgentsPage
()
{
/* ============ 智能体 Tab ============ */
function
AgentsTab
()
{
const
queryClient
=
useQueryClient
();
const
[
form
]
=
Form
.
useForm
();
// 测试对话
const
[
testModal
,
setTestModal
]
=
useState
<
{
open
:
boolean
;
agentId
:
string
;
agentName
:
string
}
>
({
open
:
false
,
agentId
:
''
,
agentName
:
''
});
const
[
testMessages
,
setTestMessages
]
=
useState
<
{
role
:
string
;
content
:
string
;
toolCalls
?:
ToolCall
[];
meta
?:
{
iterations
?:
number
;
tokens
?:
number
}
}[]
>
([]);
const
[
inputMsg
,
setInputMsg
]
=
useState
(
''
);
const
[
chatLoading
,
setChatLoading
]
=
useState
(
false
);
const
[
sessionId
,
setSessionId
]
=
useState
(
''
);
// 编辑 Agent
const
[
editModal
,
setEditModal
]
=
useState
<
{
open
:
boolean
;
agent
:
AgentDefinition
|
null
;
isNew
:
boolean
}
>
({
open
:
false
,
agent
:
null
,
isNew
:
false
});
// 可用工具列表
const
[
availableTools
,
setAvailableTools
]
=
useState
<
{
name
:
string
;
description
:
string
;
category
:
string
}[]
>
([]);
// 执行日志
const
[
logs
,
setLogs
]
=
useState
<
AgentExecutionLog
[]
>
([]);
const
[
logTotal
,
setLogTotal
]
=
useState
(
0
);
const
[
logLoading
,
setLogLoading
]
=
useState
(
false
);
const
[
logFilter
,
setLogFilter
]
=
useState
<
{
agent_id
?:
string
;
page
:
number
;
page_size
:
number
}
>
({
page
:
1
,
page_size
:
10
});
const
[
expandedLog
,
setExpandedLog
]
=
useState
<
AgentExecutionLog
|
null
>
(
null
);
// 加载 Agent 列表
const
{
data
:
agentsData
,
isLoading
:
agentsLoading
}
=
useQuery
({
queryKey
:
[
'
agent-definitions
'
],
queryFn
:
()
=>
agentApi
.
listDefinitions
(),
});
const
agents
:
AgentDefinition
[]
=
agentsData
?.
data
||
[];
// 加载工具列表
useEffect
(()
=>
{
agentApi
.
listTools
().
then
(
res
=>
{
if
(
res
.
data
?.
length
>
0
)
{
setAvailableTools
(
res
.
data
.
map
(
t
=>
({
name
:
t
.
name
,
description
:
t
.
description
,
category
:
t
.
category
})));
}
}).
catch
(()
=>
{});
fetchLogs
();
},
[]);
const
fetchLogs
=
async
(
filter
=
logFilter
)
=>
{
setLogLoading
(
true
);
try
{
const
res
=
await
agentApi
.
getExecutionLogs
(
filter
);
setLogs
(
res
.
data
?.
list
||
[]);
setLogTotal
(
res
.
data
?.
total
||
0
);
}
catch
{}
finally
{
setLogLoading
(
false
);
}
};
// 保存 Agent(创建或更新)
const
saveMutation
=
useMutation
({
mutationFn
:
(
values
:
Record
<
string
,
unknown
>
)
=>
{
const
toolsArr
=
values
.
tools_array
as
string
[]
||
[];
...
...
@@ -120,7 +101,7 @@ export default function AgentsPage() {
description
:
values
.
description
as
string
,
category
:
values
.
category
as
string
,
system_prompt
:
values
.
system_prompt
as
string
,
tools
:
toolsArr
,
tools
:
JSON
.
stringify
(
toolsArr
)
,
skills
:
skillsArr
,
max_iterations
:
values
.
max_iterations
as
number
,
status
:
values
.
status
as
string
,
...
...
@@ -140,7 +121,6 @@ export default function AgentsPage() {
onError
:
()
=>
message
.
error
(
'
操作失败
'
),
});
// 热重载
const
reloadMutation
=
useMutation
({
mutationFn
:
(
agentId
:
string
)
=>
agentApi
.
reloadAgent
(
agentId
),
onSuccess
:
()
=>
{
...
...
@@ -163,15 +143,10 @@ export default function AgentsPage() {
try
{
toolsArr
=
JSON
.
parse
(
agent
.
tools
||
'
[]
'
);
}
catch
{}
try
{
skillsArr
=
JSON
.
parse
(
agent
.
skills
||
'
[]
'
);
}
catch
{}
form
.
setFieldsValue
({
agent_id
:
agent
.
agent_id
,
name
:
agent
.
name
,
description
:
agent
.
description
,
category
:
agent
.
category
,
system_prompt
:
agent
.
system_prompt
,
tools_array
:
toolsArr
,
skills_array
:
skillsArr
,
max_iterations
:
agent
.
max_iterations
,
status
:
agent
.
status
===
'
active
'
,
agent_id
:
agent
.
agent_id
,
name
:
agent
.
name
,
description
:
agent
.
description
,
category
:
agent
.
category
,
system_prompt
:
agent
.
system_prompt
,
tools_array
:
toolsArr
,
skills_array
:
skillsArr
,
max_iterations
:
agent
.
max_iterations
,
status
:
agent
.
status
===
'
active
'
,
});
setEditModal
({
open
:
true
,
agent
,
isNew
:
false
});
}
else
{
...
...
@@ -230,12 +205,30 @@ export default function AgentsPage() {
);
};
// 按分类分组工具选项
const
toolCategoryLabels
:
Record
<
string
,
string
>
=
{
knowledge
:
'
知识库
'
,
recommendation
:
'
智能推荐
'
,
medical
:
'
病历管理
'
,
pharmacy
:
'
药品管理
'
,
safety
:
'
安全检查
'
,
follow_up
:
'
随访管理
'
,
notification
:
'
消息通知
'
,
agent
:
'
Agent调用
'
,
workflow
:
'
工作流
'
,
expression
:
'
表达式
'
,
other
:
'
其他
'
,
};
const
toolsByCategory
=
availableTools
.
reduce
((
acc
,
t
)
=>
{
const
cat
=
t
.
category
||
'
other
'
;
if
(
!
acc
[
cat
])
acc
[
cat
]
=
[];
acc
[
cat
].
push
(
t
);
return
acc
;
},
{}
as
Record
<
string
,
typeof
availableTools
>
);
const
toolOptions
=
Object
.
entries
(
toolsByCategory
).
map
(([
cat
,
tools
])
=>
({
label
:
toolCategoryLabels
[
cat
]
||
cat
,
options
:
tools
.
map
(
t
=>
({
value
:
t
.
name
,
label
:
t
.
name
,
title
:
t
.
description
})),
}));
const
agentColumns
=
[
{
title
:
'
智能体
'
,
key
:
'
name
'
,
render
:
(
_
:
unknown
,
r
:
AgentDefinition
)
=>
(
<
Space
>
<
RobotOutlined
style=
{
{
color
:
'
#
1890ff
'
}
}
/>
<
RobotOutlined
style=
{
{
color
:
'
#
0D9488
'
}
}
/>
<
div
>
<
Text
strong
>
{
r
.
name
}
</
Text
>
<
br
/>
...
...
@@ -278,72 +271,11 @@ export default function AgentsPage() {
},
];
const
logColumns
=
[
{
title
:
'
时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
width
:
160
,
render
:
(
v
:
string
)
=>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
new
Date
(
v
).
toLocaleString
(
'
zh-CN
'
)
}
</
Text
>,
},
{
title
:
'
智能体
'
,
dataIndex
:
'
agent_id
'
,
key
:
'
agent_id
'
,
width
:
160
,
render
:
(
v
:
string
)
=>
<
Tag
color=
{
categoryColor
[
v
?.
replace
(
'
_agent
'
,
''
)]
||
'
default
'
}
>
{
v
}
</
Tag
>,
},
{
title
:
'
用户ID
'
,
dataIndex
:
'
user_id
'
,
key
:
'
user_id
'
,
width
:
130
,
ellipsis
:
true
,
render
:
(
v
:
string
)
=>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
v
}
</
Text
>
},
{
title
:
'
输入摘要
'
,
dataIndex
:
'
input
'
,
key
:
'
input
'
,
ellipsis
:
true
,
render
:
(
v
:
string
)
=>
{
try
{
const
obj
=
JSON
.
parse
(
v
);
return
<
Tooltip
title=
{
obj
.
message
}
><
Text
style=
{
{
fontSize
:
12
}
}
>
{
(
obj
.
message
||
''
).
slice
(
0
,
30
)
}
...
</
Text
></
Tooltip
>;
}
catch
{
return
v
;
}
},
},
{
title
:
'
迭代
'
,
dataIndex
:
'
iterations
'
,
key
:
'
iterations
'
,
width
:
70
,
render
:
(
v
:
number
)
=>
<
Badge
count=
{
v
}
style=
{
{
backgroundColor
:
'
#722ed1
'
}
}
/>
},
{
title
:
'
Tokens
'
,
dataIndex
:
'
total_tokens
'
,
key
:
'
total_tokens
'
,
width
:
80
,
render
:
(
v
:
number
)
=>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
v
}
</
Text
>
},
{
title
:
'
耗时(ms)
'
,
dataIndex
:
'
duration_ms
'
,
key
:
'
duration_ms
'
,
width
:
90
,
render
:
(
v
:
number
)
=>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
v
}
</
Text
>
},
{
title
:
'
状态
'
,
dataIndex
:
'
success
'
,
key
:
'
success
'
,
width
:
80
,
render
:
(
v
:
boolean
)
=>
v
?
<
Badge
status=
"success"
text=
"成功"
/>
:
<
Badge
status=
"error"
text=
"失败"
/>,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
80
,
render
:
(
_
:
unknown
,
record
:
AgentExecutionLog
)
=>
(
<
Button
type=
"link"
size=
"small"
icon=
{
<
HistoryOutlined
/>
}
onClick=
{
()
=>
setExpandedLog
(
record
)
}
>
详情
</
Button
>
),
},
];
// 按分类分组工具选项
const
toolCategoryLabels
:
Record
<
string
,
string
>
=
{
knowledge
:
'
知识库
'
,
recommendation
:
'
智能推荐
'
,
medical
:
'
病历管理
'
,
pharmacy
:
'
药品管理
'
,
safety
:
'
安全检查
'
,
follow_up
:
'
随访管理
'
,
notification
:
'
消息通知
'
,
agent
:
'
Agent调用
'
,
workflow
:
'
工作流
'
,
expression
:
'
表达式
'
,
other
:
'
其他
'
,
};
const
toolsByCategory
=
availableTools
.
reduce
((
acc
,
t
)
=>
{
const
cat
=
t
.
category
||
'
other
'
;
if
(
!
acc
[
cat
])
acc
[
cat
]
=
[];
acc
[
cat
].
push
(
t
);
return
acc
;
},
{}
as
Record
<
string
,
typeof
availableTools
>
);
const
toolOptions
=
Object
.
entries
(
toolsByCategory
).
map
(([
cat
,
tools
])
=>
({
label
:
toolCategoryLabels
[
cat
]
||
cat
,
options
:
tools
.
map
(
t
=>
({
value
:
t
.
name
,
label
:
t
.
name
,
title
:
t
.
description
})),
}));
return
(
<
div
style=
{
{
padding
:
'
20px 24px
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
16
}
}
>
<
div
>
<
h2
style=
{
{
fontSize
:
20
,
fontWeight
:
700
,
color
:
'
#1d2129
'
,
margin
:
0
}
}
>
智能体管理
</
h2
>
<
div
style=
{
{
fontSize
:
13
,
color
:
'
#8c8c8c
'
,
marginTop
:
2
}
}
>
从数据库加载 Agent 配置,支持在线编辑、热重载与对话测试
</
div
>
<>
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
flex-end
'
,
marginBottom
:
12
}
}
>
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
openEdit
()
}
>
新增智能体
</
Button
>
</
div
>
<
Card
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #edf2fc
'
}
}
>
<
Tabs
tabBarExtraContent=
{
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
openEdit
()
}
>
新增 Agent
</
Button
>
}
items=
{
[
{
key
:
'
agents
'
,
label
:
<
Space
><
RobotOutlined
/>
智能体列表
</
Space
>,
children
:
(
<
Table
dataSource=
{
agents
}
columns=
{
agentColumns
}
...
...
@@ -353,70 +285,20 @@ export default function AgentsPage() {
size=
"small"
scroll=
{
{
x
:
1100
}
}
/>
),
},
{
key
:
'
logs
'
,
label
:
<
Space
><
HistoryOutlined
/>
执行日志
</
Space
>,
children
:
(
<
div
style=
{
{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
12
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
gap
:
8
,
alignItems
:
'
center
'
,
flexWrap
:
'
wrap
'
}
}
>
<
Select
placeholder=
"筛选智能体"
allowClear
style=
{
{
width
:
200
}
}
options=
{
agents
.
map
(
a
=>
({
value
:
a
.
agent_id
,
label
:
a
.
name
}))
}
onChange=
{
v
=>
{
const
newFilter
=
{
...
logFilter
,
agent_id
:
v
,
page
:
1
};
setLogFilter
(
newFilter
);
fetchLogs
(
newFilter
);
}
}
/>
<
RangePicker
onChange=
{
(
_
,
strs
)
=>
{
const
newFilter
=
{
...
logFilter
,
...(
strs
[
0
]
?
{
start
:
strs
[
0
]
}
:
{}),
...(
strs
[
1
]
?
{
end
:
strs
[
1
]
}
:
{}),
page
:
1
};
setLogFilter
(
newFilter
);
fetchLogs
(
newFilter
);
}
}
/>
<
Button
icon=
{
<
ThunderboltOutlined
/>
}
onClick=
{
()
=>
fetchLogs
()
}
>
刷新
</
Button
>
</
div
>
<
Table
dataSource=
{
logs
}
columns=
{
logColumns
}
rowKey=
"id"
loading=
{
logLoading
}
size=
"small"
pagination=
{
{
current
:
logFilter
.
page
,
pageSize
:
logFilter
.
page_size
,
total
:
logTotal
,
size
:
'
small
'
,
showTotal
:
(
t
)
=>
`共 ${t} 条`
,
onChange
:
(
page
,
pageSize
)
=>
{
const
newFilter
=
{
...
logFilter
,
page
,
page_size
:
pageSize
};
setLogFilter
(
newFilter
);
fetchLogs
(
newFilter
);
},
}
}
/>
</
div
>
),
},
]
}
/>
</
Card
>
{
/* 新增/编辑 Agent
DrawerForm
*/
}
<
DrawerForm
title=
{
editModal
.
isNew
?
'
新增
Agent
'
:
`编辑 · ${editModal.agent?.name}`
}
{
/* 新增/编辑 Agent
Modal
*/
}
<
Modal
title=
{
editModal
.
isNew
?
'
新增
智能体
'
:
`编辑 · ${editModal.agent?.name}`
}
open=
{
editModal
.
open
}
onOpenChange=
{
(
open
)
=>
{
if
(
!
open
)
{
setEditModal
({
open
:
false
,
agent
:
null
,
isNew
:
false
});
form
.
resetFields
();
}
}
}
onFinish=
{
async
(
values
)
=>
{
saveMutation
.
mutate
({
...
values
,
status
:
values
.
status
?
'
active
'
:
'
disabled
'
});
return
true
;
}
}
drawerProps=
{
{
placement
:
'
right
'
,
destroyOnClose
:
true
}
}
width=
{
600
}
loading=
{
saveMutation
.
isPending
}
onCancel=
{
()
=>
{
setEditModal
({
open
:
false
,
agent
:
null
,
isNew
:
false
});
form
.
resetFields
();
}
}
onOk=
{
()
=>
form
.
submit
()
}
confirmLoading=
{
saveMutation
.
isPending
}
width=
{
700
}
>
<
Form
form=
{
form
}
layout=
"vertical"
onFinish=
{
(
values
)
=>
saveMutation
.
mutate
({
...
values
,
status
:
values
.
status
?
'
active
'
:
'
disabled
'
})
}
>
<
Form
.
Item
name=
"agent_id"
label=
"Agent ID"
rules=
{
[{
required
:
true
,
message
:
'
请输入 Agent ID
'
}]
}
>
<
Input
placeholder=
"如: doctor_universal_agent"
disabled=
{
!
editModal
.
isNew
}
/>
...
...
@@ -450,23 +332,23 @@ export default function AgentsPage() {
<
Form
.
Item
name=
"status"
label=
"状态"
valuePropName=
"checked"
>
<
Switch
checkedChildren=
"启用"
unCheckedChildren=
"停用"
/>
</
Form
.
Item
>
</
DrawerForm
>
</
Form
>
</
Modal
>
{
/* 测试对话
Drawer
*/
}
<
Drawer
{
/* 测试对话
Modal
*/
}
<
Modal
title=
{
`测试 · ${testModal.agentName}`
}
open=
{
testModal
.
open
}
onClose=
{
()
=>
setTestModal
({
open
:
false
,
agentId
:
''
,
agentName
:
''
})
}
placement=
"right"
destroyOnClose
width=
{
600
}
onCancel=
{
()
=>
setTestModal
({
open
:
false
,
agentId
:
''
,
agentName
:
''
})
}
footer=
{
null
}
width=
{
700
}
>
<
div
style=
{
{
height
:
400
,
overflowY
:
'
auto
'
,
border
:
'
1px solid #f0f0f0
'
,
borderRadius
:
8
,
padding
:
12
,
marginBottom
:
12
}
}
>
{
testMessages
.
map
((
m
,
i
)
=>
(
<
div
key=
{
i
}
style=
{
{
marginBottom
:
12
,
textAlign
:
m
.
role
===
'
user
'
?
'
right
'
:
'
left
'
}
}
>
<
div
style=
{
{
display
:
'
inline-block
'
,
padding
:
'
8px 14px
'
,
borderRadius
:
8
,
maxWidth
:
'
85%
'
,
background
:
m
.
role
===
'
user
'
?
'
#
1890ff
'
:
'
#f5f5f5
'
,
background
:
m
.
role
===
'
user
'
?
'
#
0D9488
'
:
'
#f5f5f5
'
,
color
:
m
.
role
===
'
user
'
?
'
#fff
'
:
'
#333
'
,
textAlign
:
'
left
'
,
}
}
>
...
...
@@ -486,45 +368,131 @@ export default function AgentsPage() {
<
Input
value=
{
inputMsg
}
onChange=
{
e
=>
setInputMsg
(
e
.
target
.
value
)
}
onPressEnter=
{
sendMessage
}
placeholder=
"输入消息..."
/>
<
Button
type=
"primary"
onClick=
{
sendMessage
}
loading=
{
chatLoading
}
>
发送
</
Button
>
</
Space
.
Compact
>
</
Drawer
>
</
Modal
>
</>
);
}
/* ============ 工具 Tab (内嵌子Tab) ============ */
function
ToolsTab
()
{
const
[
search
,
setSearch
]
=
useState
(
''
);
const
[
categoryFilter
,
setCategoryFilter
]
=
useState
(
'
all
'
);
const
[
activeSubTab
,
setActiveSubTab
]
=
useState
(
'
all
'
);
const
{
data
:
builtinData
}
=
useQuery
({
queryKey
:
[
'
agent-tools
'
],
queryFn
:
()
=>
agentApi
.
listTools
(),
select
:
r
=>
(
r
.
data
??
[])
as
{
name
:
string
;
is_enabled
:
boolean
;
category
:
string
}[],
});
const
{
data
:
httpData
}
=
useQuery
({
queryKey
:
[
'
http-tools
'
],
queryFn
:
()
=>
httpToolApi
.
list
(),
select
:
r
=>
r
.
data
??
[],
});
const
totalCount
=
(
builtinData
?.
length
||
0
)
+
(
httpData
?.
length
||
0
);
const
enabledCount
=
(
builtinData
?.
filter
(
t
=>
t
.
is_enabled
).
length
||
0
)
+
(
httpData
?.
filter
(
t
=>
t
.
status
===
'
active
'
).
length
||
0
);
const
allCategories
=
useMemo
(()
=>
{
const
cats
=
new
Set
<
string
>
();
builtinData
?.
forEach
(
t
=>
cats
.
add
(
t
.
category
));
httpData
?.
forEach
(
t
=>
cats
.
add
(
t
.
category
||
'
http
'
));
return
[...
cats
];
},
[
builtinData
,
httpData
]);
{
/* 执行日志详情 Drawer */
}
<
Drawer
title=
{
<
Space
><
HistoryOutlined
/>
执行日志详情
</
Space
>
}
open=
{
!!
expandedLog
}
onClose=
{
()
=>
setExpandedLog
(
null
)
}
placement=
"right"
destroyOnClose
width=
{
600
}
>
{
expandedLog
&&
(()
=>
{
let
toolCalls
:
ToolCall
[]
=
[];
try
{
toolCalls
=
JSON
.
parse
(
expandedLog
.
tool_calls
||
'
[]
'
);
}
catch
{}
let
inputObj
:
Record
<
string
,
unknown
>
=
{};
try
{
inputObj
=
JSON
.
parse
(
expandedLog
.
input
||
'
{}
'
);
}
catch
{}
let
outputObj
:
Record
<
string
,
unknown
>
=
{};
try
{
outputObj
=
JSON
.
parse
(
expandedLog
.
output
||
'
{}
'
);
}
catch
{}
return
(
<
div
style=
{
{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
12
}
}
>
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
1fr 1fr
'
,
gap
:
8
,
fontSize
:
13
}
}
>
<
div
><
Text
type=
"secondary"
>
智能体:
</
Text
><
Tag
>
{
expandedLog
.
agent_id
}
</
Tag
></
div
>
<
div
><
Text
type=
"secondary"
>
状态:
</
Text
><
Badge
status=
{
expandedLog
.
success
?
'
success
'
:
'
error
'
}
text=
{
expandedLog
.
success
?
'
成功
'
:
'
失败
'
}
/></
div
>
<
div
><
Text
type=
"secondary"
>
迭代次数:
</
Text
>
{
expandedLog
.
iterations
}
</
div
>
<
div
><
Text
type=
"secondary"
>
耗时:
</
Text
>
{
expandedLog
.
duration_ms
}
ms
</
div
>
<
div
><
Text
type=
"secondary"
>
Tokens:
</
Text
>
{
expandedLog
.
total_tokens
}
</
div
>
<
div
><
Text
type=
"secondary"
>
完成原因:
</
Text
>
{
expandedLog
.
finish_reason
||
'
-
'
}
</
div
>
{
/* 统计 + 筛选 */
}
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
,
flexWrap
:
'
wrap
'
,
}
}
>
<
Input
placeholder=
"搜索工具名称或描述..."
prefix=
{
<
SearchOutlined
/>
}
value=
{
search
}
onChange=
{
e
=>
setSearch
(
e
.
target
.
value
)
}
style=
{
{
width
:
240
,
borderRadius
:
8
}
}
allowClear
/>
{
(
activeSubTab
===
'
all
'
||
activeSubTab
===
'
builtin
'
)
&&
(
<
Segmented
value=
{
categoryFilter
}
onChange=
{
v
=>
setCategoryFilter
(
v
as
string
)
}
options=
{
[
{
value
:
'
all
'
,
label
:
'
全部分类
'
},
...
allCategories
.
map
(
c
=>
({
value
:
c
,
label
:
CATEGORY_CONFIG
[
c
]?.
label
||
c
})),
]
}
/>
)
}
<
div
style=
{
{
marginLeft
:
'
auto
'
,
fontSize
:
13
,
color
:
'
#8c8c8c
'
}
}
>
共
<
Text
strong
>
{
totalCount
}
</
Text
>
个工具,已启用
<
Text
strong
style=
{
{
color
:
'
#52c41a
'
}
}
>
{
enabledCount
}
</
Text
>
个
</
div
>
<
Card
size=
"small"
title=
"用户输入"
>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
(
inputObj
.
message
as
string
)
||
expandedLog
.
input
}
</
Text
>
</
Card
>
<
Card
size=
"small"
title=
"AI 回复"
>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
(
outputObj
.
response
as
string
)
||
expandedLog
.
output
}
</
Text
>
</
Card
>
{
renderToolCalls
(
toolCalls
)
}
</
div
>
<
Tabs
activeKey=
{
activeSubTab
}
onChange=
{
setActiveSubTab
}
size=
"small"
items=
{
[
{
key
:
'
all
'
,
label
:
<
span
><
AppstoreOutlined
/>
全部
</
span
>,
children
:
<
AllToolsTab
search=
{
search
}
categoryFilter=
{
categoryFilter
}
CATEGORY_CONFIG=
{
CATEGORY_CONFIG
}
SOURCE_CONFIG=
{
SOURCE_CONFIG
}
/>,
},
{
key
:
'
builtin
'
,
label
:
<
span
><
ToolOutlined
/>
内置工具
</
span
>,
children
:
<
BuiltinToolsTab
search=
{
search
}
categoryFilter=
{
categoryFilter
}
CATEGORY_CONFIG=
{
CATEGORY_CONFIG
}
/>,
},
{
key
:
'
http
'
,
label
:
<
span
><
CloudOutlined
/>
HTTP 工具
</
span
>,
children
:
<
HTTPToolsTab
search=
{
search
}
/>,
},
]
}
/>
</
div
>
);
})()
}
</
Drawer
>
}
/* ============ 主页面 ============ */
export
default
function
AgentManagementPage
()
{
const
[
activeTab
,
setActiveTab
]
=
useState
(
'
agents
'
);
return
(
<
div
style=
{
{
padding
:
'
20px 24px
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
16
}
}
>
<
div
>
<
h2
style=
{
{
fontSize
:
20
,
fontWeight
:
700
,
color
:
'
#1d2129
'
,
margin
:
0
}
}
>
<
RobotOutlined
style=
{
{
marginRight
:
8
,
color
:
'
#0D9488
'
}
}
/>
智能体管理
</
h2
>
<
div
style=
{
{
fontSize
:
13
,
color
:
'
#8c8c8c
'
,
marginTop
:
2
}
}
>
管理智能体、工具与技能,支持在线编辑、热重载与对话测试
</
div
>
</
div
>
<
Card
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Tabs
activeKey=
{
activeTab
}
onChange=
{
setActiveTab
}
items=
{
[
{
key
:
'
agents
'
,
label
:
<
Space
><
RobotOutlined
/>
智能体
</
Space
>,
children
:
<
AgentsTab
/>,
},
{
key
:
'
tools
'
,
label
:
<
Space
><
ToolOutlined
/>
Tools 工具
</
Space
>,
children
:
<
ToolsTab
/>,
},
{
key
:
'
skills
'
,
label
:
<
Space
><
ThunderboltOutlined
/>
Skill 技能
</
Space
>,
children
:
<
SkillsTab
search=
""
/>,
},
]
}
/>
</
Card
>
</
div
>
);
}
web/src/app/(main)/admin/agents/toolsConfig.ts
0 → 100644
View file @
04584395
import
React
from
'
react
'
;
import
{
ToolOutlined
,
BookOutlined
,
ThunderboltOutlined
,
HeartOutlined
,
SafetyOutlined
,
BellOutlined
,
RobotOutlined
,
DeploymentUnitOutlined
,
CodeOutlined
,
ApiOutlined
,
}
from
'
@ant-design/icons
'
;
export
const
CATEGORY_CONFIG
:
Record
<
string
,
{
color
:
string
;
label
:
string
;
icon
:
React
.
ReactNode
}
>
=
{
knowledge
:
{
color
:
'
blue
'
,
label
:
'
知识库
'
,
icon
:
React
.
createElement
(
BookOutlined
)
},
recommendation
:
{
color
:
'
cyan
'
,
label
:
'
智能推荐
'
,
icon
:
React
.
createElement
(
ThunderboltOutlined
)
},
medical
:
{
color
:
'
purple
'
,
label
:
'
病历管理
'
,
icon
:
React
.
createElement
(
HeartOutlined
)
},
pharmacy
:
{
color
:
'
green
'
,
label
:
'
药品管理
'
,
icon
:
React
.
createElement
(
ToolOutlined
)
},
safety
:
{
color
:
'
red
'
,
label
:
'
安全检查
'
,
icon
:
React
.
createElement
(
SafetyOutlined
)
},
follow_up
:
{
color
:
'
orange
'
,
label
:
'
随访管理
'
,
icon
:
React
.
createElement
(
HeartOutlined
)
},
notification
:
{
color
:
'
gold
'
,
label
:
'
消息通知
'
,
icon
:
React
.
createElement
(
BellOutlined
)
},
agent
:
{
color
:
'
geekblue
'
,
label
:
'
Agent调用
'
,
icon
:
React
.
createElement
(
RobotOutlined
)
},
workflow
:
{
color
:
'
volcano
'
,
label
:
'
工作流
'
,
icon
:
React
.
createElement
(
DeploymentUnitOutlined
)
},
expression
:
{
color
:
'
lime
'
,
label
:
'
表达式
'
,
icon
:
React
.
createElement
(
CodeOutlined
)
},
http
:
{
color
:
'
magenta
'
,
label
:
'
HTTP服务
'
,
icon
:
React
.
createElement
(
ApiOutlined
)
},
other
:
{
color
:
'
default
'
,
label
:
'
其他
'
,
icon
:
React
.
createElement
(
ToolOutlined
)
},
};
export
type
CATEGORY_CONFIG_TYPE
=
typeof
CATEGORY_CONFIG
;
export
const
SOURCE_CONFIG
=
{
builtin
:
{
label
:
'
内置
'
,
color
:
'
#0D9488
'
},
http
:
{
label
:
'
HTTP
'
,
color
:
'
#fa8c16
'
},
}
as
const
;
export
type
SOURCE_CONFIG_TYPE
=
typeof
SOURCE_CONFIG
;
web/src/app/(main)/admin/ai-logs/page.tsx
0 → 100644
View file @
04584395
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Button
,
Space
,
Modal
,
Typography
,
Row
,
Col
,
Statistic
,
Tabs
,
Input
,
Select
,
Timeline
,
Spin
,
Empty
,
DatePicker
,
Badge
,
Collapse
,
}
from
'
antd
'
;
import
{
FileTextOutlined
,
SearchOutlined
,
ToolOutlined
,
RobotOutlined
,
ThunderboltOutlined
,
CheckCircleOutlined
,
CloseCircleOutlined
,
HistoryOutlined
,
FundOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useQuery
}
from
'
@tanstack/react-query
'
;
import
{
agentApi
}
from
'
@/api/agent
'
;
import
type
{
AgentExecutionLog
,
ToolCall
}
from
'
@/api/agent
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
dayjs
from
'
dayjs
'
;
const
{
Text
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
/* ---- AI 调用日志类型 ---- */
interface
AIUsageLog
{
id
:
number
;
scene
:
string
;
user_id
:
string
;
provider
:
string
;
model
:
string
;
total_tokens
:
number
;
response_time_ms
:
number
;
success
:
boolean
;
is_mock
:
boolean
;
trace_id
:
string
;
agent_id
:
string
;
session_id
:
string
;
iteration
:
number
;
created_at
:
string
;
}
interface
TraceDetail
{
trace_id
:
string
;
execution_logs
:
unknown
[];
llm_calls
:
AIUsageLog
[];
tool_calls
:
{
tool_name
:
string
;
iteration
:
number
;
success
:
boolean
;
duration_ms
:
number
}[];
}
const
token
=
()
=>
localStorage
.
getItem
(
'
access_token
'
)
||
''
;
async
function
fetchAIStats
(
page
:
number
,
agentID
:
string
,
startDate
?:
string
,
endDate
?:
string
)
{
const
params
=
new
URLSearchParams
({
page
:
String
(
page
),
page_size
:
'
20
'
});
if
(
agentID
)
params
.
append
(
'
agent_id
'
,
agentID
);
if
(
startDate
)
params
.
append
(
'
start_date
'
,
startDate
);
if
(
endDate
)
params
.
append
(
'
end_date
'
,
endDate
);
const
res
=
await
fetch
(
`/api/v1/admin/ai-center/stats?
${
params
}
`
,
{
headers
:
{
Authorization
:
`Bearer
${
token
()}
`
},
});
const
data
=
await
res
.
json
();
return
data
.
data
??
{};
}
async
function
fetchTrace
(
traceID
:
string
)
{
const
res
=
await
fetch
(
`/api/v1/admin/ai-center/trace?trace_id=
${
traceID
}
`
,
{
headers
:
{
Authorization
:
`Bearer
${
token
()}
`
},
});
const
data
=
await
res
.
json
();
return
data
.
data
as
TraceDetail
;
}
// AI日志页面
export
default
function
AILogsPage
()
{
const
[
activeTab
,
setActiveTab
]
=
useState
(
'
execution
'
);
// 执行日志状态
const
[
execLogs
,
setExecLogs
]
=
useState
<
AgentExecutionLog
[]
>
([]);
const
[
execTotal
,
setExecTotal
]
=
useState
(
0
);
const
[
execLoading
,
setExecLoading
]
=
useState
(
false
);
const
[
execFilter
,
setExecFilter
]
=
useState
<
{
agent_id
?:
string
;
page
:
number
;
page_size
:
number
;
start
?:
string
;
end
?:
string
;
}
>
({
page
:
1
,
page_size
:
20
,
start
:
dayjs
().
format
(
'
YYYY-MM-DD
'
),
end
:
dayjs
().
format
(
'
YYYY-MM-DD
'
),
});
const
[
expandedLog
,
setExpandedLog
]
=
useState
<
AgentExecutionLog
|
null
>
(
null
);
// AI 调用日志状态
const
[
aiPage
,
setAiPage
]
=
useState
(
1
);
const
[
aiAgentFilter
,
setAiAgentFilter
]
=
useState
(
''
);
const
[
aiDateRange
,
setAiDateRange
]
=
useState
<
[
string
,
string
]
>
([
dayjs
().
format
(
'
YYYY-MM-DD
'
),
dayjs
().
format
(
'
YYYY-MM-DD
'
),
]);
const
[
traceSearch
,
setTraceSearch
]
=
useState
(
''
);
const
[
traceModalOpen
,
setTraceModalOpen
]
=
useState
(
false
);
const
[
selectedTraceID
,
setSelectedTraceID
]
=
useState
(
''
);
// 获取 Agent 列表
const
{
data
:
agentsData
}
=
useQuery
({
queryKey
:
[
'
agent-definitions
'
],
queryFn
:
()
=>
agentApi
.
listDefinitions
(),
});
const
agents
=
agentsData
?.
data
||
[];
// 获取 AI 调用统计
const
{
data
:
aiStats
,
isLoading
:
aiLoading
}
=
useQuery
({
queryKey
:
[
'
ai-logs-stats
'
,
aiPage
,
aiAgentFilter
,
aiDateRange
],
queryFn
:
()
=>
fetchAIStats
(
aiPage
,
aiAgentFilter
,
aiDateRange
[
0
],
aiDateRange
[
1
]),
refetchInterval
:
60000
,
});
// 链路追踪详情
const
{
data
:
traceDetail
,
isFetching
:
traceFetching
}
=
useQuery
({
queryKey
:
[
'
trace-detail
'
,
selectedTraceID
],
queryFn
:
()
=>
fetchTrace
(
selectedTraceID
),
enabled
:
!!
selectedTraceID
&&
traceModalOpen
,
});
// 获取执行日志
const
fetchExecLogs
=
async
(
filter
=
execFilter
)
=>
{
setExecLoading
(
true
);
try
{
const
res
=
await
agentApi
.
getExecutionLogs
(
filter
);
setExecLogs
(
res
.
data
?.
list
||
[]);
setExecTotal
(
res
.
data
?.
total
||
0
);
}
catch
{}
finally
{
setExecLoading
(
false
);
}
};
// 初始加载执行日志
React
.
useEffect
(()
=>
{
fetchExecLogs
();
},
[]);
const
openTrace
=
(
traceID
:
string
)
=>
{
setSelectedTraceID
(
traceID
);
setTraceModalOpen
(
true
);
};
const
renderToolCalls
=
(
toolCalls
?:
ToolCall
[])
=>
{
if
(
!
toolCalls
||
toolCalls
.
length
===
0
)
return
null
;
return
(
<
Collapse
size=
"small"
style=
{
{
marginTop
:
8
}
}
items=
{
[{
key
:
'
tools
'
,
label
:
<
span
style=
{
{
fontSize
:
12
}
}
><
ToolOutlined
style=
{
{
marginRight
:
4
}
}
/>
Tool调用记录 (
{
toolCalls
.
length
}
)
</
span
>,
children
:
(
<
Timeline
items=
{
toolCalls
.
map
((
tc
,
idx
)
=>
({
color
:
tc
.
success
?
'
green
'
:
'
red
'
,
dot
:
tc
.
success
?
<
CheckCircleOutlined
/>
:
<
CloseCircleOutlined
/>,
children
:
(
<
div
key=
{
idx
}
style=
{
{
fontSize
:
12
}
}
>
<
div
style=
{
{
fontWeight
:
500
}
}
>
{
tc
.
tool_name
}
</
div
>
<
div
style=
{
{
color
:
'
#8c8c8c
'
}
}
>
参数:
{
tc
.
arguments
}
</
div
>
{
tc
.
result
&&
(
<
div
style=
{
{
color
:
tc
.
success
?
'
#52c41a
'
:
'
#ff4d4f
'
}
}
>
{
tc
.
success
?
JSON
.
stringify
(
tc
.
result
.
data
).
slice
(
0
,
100
)
+
(
JSON
.
stringify
(
tc
.
result
.
data
).
length
>
100
?
'
...
'
:
''
)
:
tc
.
result
.
error
}
</
div
>
)
}
</
div
>
),
}))
}
/>
),
}]
}
/>
);
};
/* ---- 执行日志列定义 ---- */
const
execColumns
:
ColumnsType
<
AgentExecutionLog
>
=
[
{
title
:
'
时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
width
:
160
,
render
:
(
v
:
string
)
=>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
dayjs
(
v
).
format
(
'
MM-DD HH:mm:ss
'
)
}
</
Text
>,
},
{
title
:
'
智能体
'
,
dataIndex
:
'
agent_id
'
,
key
:
'
agent_id
'
,
width
:
160
,
render
:
(
v
:
string
)
=>
{
const
agent
=
agents
.
find
(
a
=>
a
.
agent_id
===
v
);
return
<
Tag
color=
"blue"
>
{
agent
?.
name
||
v
}
</
Tag
>;
},
},
{
title
:
'
用户ID
'
,
dataIndex
:
'
user_id
'
,
key
:
'
user_id
'
,
width
:
130
,
ellipsis
:
true
,
render
:
(
v
:
string
)
=>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
v
}
</
Text
>
},
{
title
:
'
输入摘要
'
,
dataIndex
:
'
input
'
,
key
:
'
input
'
,
ellipsis
:
true
,
render
:
(
v
:
string
)
=>
{
try
{
const
obj
=
JSON
.
parse
(
v
);
const
msg
=
(
obj
.
message
||
''
)
as
string
;
return
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
msg
.
slice
(
0
,
40
)
}{
msg
.
length
>
40
?
'
...
'
:
''
}
</
Text
>;
}
catch
{
return
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
v
}
</
Text
>;
}
},
},
{
title
:
'
迭代
'
,
dataIndex
:
'
iterations
'
,
key
:
'
iterations
'
,
width
:
70
,
render
:
(
v
:
number
)
=>
<
Badge
count=
{
v
}
style=
{
{
backgroundColor
:
'
#0891B2
'
}
}
/>
},
{
title
:
'
Tokens
'
,
dataIndex
:
'
total_tokens
'
,
key
:
'
total_tokens
'
,
width
:
80
,
render
:
(
v
:
number
)
=>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
v
?.
toLocaleString
()
}
</
Text
>
},
{
title
:
'
耗时(ms)
'
,
dataIndex
:
'
duration_ms
'
,
key
:
'
duration_ms
'
,
width
:
90
,
render
:
(
v
:
number
)
=>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
v
}
</
Text
>
},
{
title
:
'
状态
'
,
dataIndex
:
'
success
'
,
key
:
'
success
'
,
width
:
80
,
render
:
(
v
:
boolean
)
=>
v
?
<
Badge
status=
"success"
text=
"成功"
/>
:
<
Badge
status=
"error"
text=
"失败"
/>,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
80
,
render
:
(
_
:
unknown
,
record
:
AgentExecutionLog
)
=>
(
<
Button
type=
"link"
size=
"small"
icon=
{
<
HistoryOutlined
/>
}
onClick=
{
()
=>
setExpandedLog
(
record
)
}
>
详情
</
Button
>
),
},
];
/* ---- AI 调用日志列定义 ---- */
const
aiLogColumns
:
ColumnsType
<
AIUsageLog
>
=
[
{
title
:
'
TraceID
'
,
dataIndex
:
'
trace_id
'
,
width
:
140
,
render
:
(
v
)
=>
v
?
(
<
Button
type=
"link"
size=
"small"
style=
{
{
padding
:
0
}
}
onClick=
{
()
=>
openTrace
(
v
)
}
>
{
v
.
slice
(
0
,
12
)
}
...
</
Button
>
)
:
'
-
'
,
},
{
title
:
'
场景
'
,
dataIndex
:
'
scene
'
,
width
:
140
,
render
:
(
v
)
=>
<
Tag
>
{
v
}
</
Tag
>
},
{
title
:
'
Agent
'
,
dataIndex
:
'
agent_id
'
,
width
:
140
,
render
:
(
v
)
=>
v
?
<
Tag
color=
"blue"
>
{
v
}
</
Tag
>
:
'
-
'
},
{
title
:
'
迭代
'
,
dataIndex
:
'
iteration
'
,
width
:
60
,
render
:
(
v
)
=>
v
>
0
?
<
Tag
color=
"purple"
>
#
{
v
}
</
Tag
>
:
'
-
'
},
{
title
:
'
Tokens
'
,
dataIndex
:
'
total_tokens
'
,
width
:
80
,
render
:
(
v
)
=>
<
Text
>
{
v
?.
toLocaleString
()
}
</
Text
>
},
{
title
:
'
耗时(ms)
'
,
dataIndex
:
'
response_time_ms
'
,
width
:
90
,
render
:
(
v
)
=>
{
const
color
=
v
>
5000
?
'
#ff4d4f
'
:
v
>
2000
?
'
#fa8c16
'
:
'
#52c41a
'
;
return
<
Text
style=
{
{
color
}
}
>
{
v
}
</
Text
>;
},
},
{
title
:
'
状态
'
,
dataIndex
:
'
success
'
,
width
:
70
,
render
:
(
v
,
r
)
=>
(
<
Tag
color=
{
!
v
?
'
error
'
:
r
.
is_mock
?
'
warning
'
:
'
success
'
}
>
{
!
v
?
'
失败
'
:
r
.
is_mock
?
'
模拟
'
:
'
成功
'
}
</
Tag
>
),
},
{
title
:
'
时间
'
,
dataIndex
:
'
created_at
'
,
width
:
140
,
render
:
(
v
)
=>
dayjs
(
v
).
format
(
'
MM-DD HH:mm:ss
'
)
},
];
return
(
<
div
style=
{
{
padding
:
'
20px 24px
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
16
}
}
>
<
div
>
<
h2
style=
{
{
fontSize
:
20
,
fontWeight
:
700
,
color
:
'
#1d2129
'
,
margin
:
0
}
}
>
<
FileTextOutlined
style=
{
{
marginRight
:
8
,
color
:
'
#0D9488
'
}
}
/>
AI 日志
</
h2
>
<
div
style=
{
{
fontSize
:
13
,
color
:
'
#8c8c8c
'
,
marginTop
:
2
}
}
>
智能体执行日志与 AI 调用追踪,默认查询当天数据
</
div
>
</
div
>
{
/* 统计卡片 */
}
<
Row
gutter=
{
[
16
,
16
]
}
>
<
Col
span=
{
4
}
>
<
Card
loading=
{
aiLoading
}
size=
"small"
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Statistic
title=
"总调用"
value=
{
aiStats
?.
total_calls
??
0
}
prefix=
{
<
ThunderboltOutlined
/>
}
/>
</
Card
>
</
Col
>
<
Col
span=
{
4
}
>
<
Card
loading=
{
aiLoading
}
size=
"small"
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Statistic
title=
"成功率"
value=
{
aiStats
?.
total_calls
?
Math
.
round
((
aiStats
.
success_calls
/
aiStats
.
total_calls
)
*
100
)
:
0
}
suffix=
"%"
valueStyle=
{
{
color
:
'
#52c41a
'
}
}
/>
</
Card
>
</
Col
>
<
Col
span=
{
4
}
>
<
Card
loading=
{
aiLoading
}
size=
"small"
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Statistic
title=
"总 Tokens"
value=
{
aiStats
?.
total_tokens
??
0
}
/>
</
Card
>
</
Col
>
<
Col
span=
{
4
}
>
<
Card
loading=
{
aiLoading
}
size=
"small"
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Statistic
title=
"Agent 执行"
value=
{
aiStats
?.
agent_execs
??
0
}
prefix=
{
<
RobotOutlined
/>
}
/>
</
Card
>
</
Col
>
<
Col
span=
{
4
}
>
<
Card
loading=
{
aiLoading
}
size=
"small"
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Statistic
title=
"工具调用"
value=
{
aiStats
?.
tool_calls
??
0
}
prefix=
{
<
ToolOutlined
/>
}
/>
</
Card
>
</
Col
>
<
Col
span=
{
4
}
>
<
Card
loading=
{
aiLoading
}
size=
"small"
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Statistic
title=
"模拟调用"
value=
{
aiStats
?.
mock_calls
??
0
}
valueStyle=
{
{
color
:
'
#fa8c16
'
}
}
/>
</
Card
>
</
Col
>
</
Row
>
<
Card
style=
{
{
borderRadius
:
12
,
border
:
'
1px solid #E0F2F1
'
}
}
>
<
Tabs
activeKey=
{
activeTab
}
onChange=
{
setActiveTab
}
items=
{
[
{
key
:
'
execution
'
,
label
:
<
Space
><
RobotOutlined
/>
智能体执行日志
</
Space
>,
children
:
(
<
div
style=
{
{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
12
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
gap
:
8
,
alignItems
:
'
center
'
,
flexWrap
:
'
wrap
'
}
}
>
<
Select
placeholder=
"筛选智能体"
allowClear
style=
{
{
width
:
200
}
}
options=
{
agents
.
map
(
a
=>
({
value
:
a
.
agent_id
,
label
:
a
.
name
}))
}
onChange=
{
v
=>
{
const
f
=
{
...
execFilter
,
agent_id
:
v
,
page
:
1
};
setExecFilter
(
f
);
fetchExecLogs
(
f
);
}
}
/>
<
RangePicker
defaultValue=
{
[
dayjs
(),
dayjs
()]
}
onChange=
{
(
dates
)
=>
{
const
start
=
dates
?.[
0
]?.
format
(
'
YYYY-MM-DD
'
)
||
''
;
const
end
=
dates
?.[
1
]?.
format
(
'
YYYY-MM-DD
'
)
||
''
;
const
f
=
{
...
execFilter
,
start
,
end
,
page
:
1
};
setExecFilter
(
f
);
fetchExecLogs
(
f
);
}
}
/>
<
Button
icon=
{
<
ThunderboltOutlined
/>
}
onClick=
{
()
=>
fetchExecLogs
()
}
>
刷新
</
Button
>
</
div
>
<
Table
dataSource=
{
execLogs
}
columns=
{
execColumns
}
rowKey=
"id"
loading=
{
execLoading
}
size=
"small"
pagination=
{
{
current
:
execFilter
.
page
,
pageSize
:
execFilter
.
page_size
,
total
:
execTotal
,
size
:
'
small
'
,
showTotal
:
(
t
)
=>
`共 ${t} 条`
,
onChange
:
(
page
,
pageSize
)
=>
{
const
f
=
{
...
execFilter
,
page
,
page_size
:
pageSize
};
setExecFilter
(
f
);
fetchExecLogs
(
f
);
},
}
}
/>
</
div
>
),
},
{
key
:
'
ai-calls
'
,
label
:
<
Space
><
FundOutlined
/>
AI 调用日志
</
Space
>,
children
:
(
<
div
style=
{
{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
12
}
}
>
<
Space
style=
{
{
flexWrap
:
'
wrap
'
}
}
>
<
Select
placeholder=
"按 Agent 过滤"
allowClear
style=
{
{
width
:
200
}
}
value=
{
aiAgentFilter
||
undefined
}
onChange=
{
(
v
)
=>
{
setAiAgentFilter
(
v
??
''
);
setAiPage
(
1
);
}
}
options=
{
agents
.
map
(
a
=>
({
value
:
a
.
agent_id
,
label
:
a
.
name
}))
}
/>
<
RangePicker
defaultValue=
{
[
dayjs
(),
dayjs
()]
}
onChange=
{
(
dates
)
=>
{
const
start
=
dates
?.[
0
]?.
format
(
'
YYYY-MM-DD
'
)
||
dayjs
().
format
(
'
YYYY-MM-DD
'
);
const
end
=
dates
?.[
1
]?.
format
(
'
YYYY-MM-DD
'
)
||
dayjs
().
format
(
'
YYYY-MM-DD
'
);
setAiDateRange
([
start
,
end
]);
setAiPage
(
1
);
}
}
/>
<
Input
.
Search
placeholder=
"按 TraceID 追踪"
value=
{
traceSearch
}
onChange=
{
(
e
)
=>
setTraceSearch
(
e
.
target
.
value
)
}
onSearch=
{
(
v
)
=>
v
&&
openTrace
(
v
)
}
style=
{
{
width
:
280
}
}
prefix=
{
<
SearchOutlined
/>
}
/>
</
Space
>
<
Table
columns=
{
aiLogColumns
}
dataSource=
{
aiStats
?.
recent_logs
??
[]
}
rowKey=
"id"
loading=
{
aiLoading
}
size=
"small"
pagination=
{
{
current
:
aiPage
,
total
:
aiStats
?.
logs_total
??
0
,
pageSize
:
20
,
onChange
:
setAiPage
,
showTotal
:
(
t
)
=>
`共 ${t} 条`
,
}
}
/>
</
div
>
),
},
{
key
:
'
agent-stats
'
,
label
:
<
Space
><
FundOutlined
/>
统计分析
</
Space
>,
children
:
(
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Card
title=
"各 Agent 调用量 TOP 10"
size=
"small"
>
{
(
aiStats
?.
agent_counts
??
[]).
map
((
item
:
{
agent_id
:
string
;
count
:
number
},
idx
:
number
)
=>
(
<
div
key=
{
item
.
agent_id
}
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
space-between
'
,
padding
:
'
4px 0
'
,
borderBottom
:
'
1px solid #f0f0f0
'
}
}
>
<
Text
>
{
idx
+
1
}
.
{
item
.
agent_id
}
</
Text
>
<
Tag
color=
"blue"
>
{
item
.
count
}
</
Tag
>
</
div
>
))
}
{
(
aiStats
?.
agent_counts
??
[]).
length
===
0
&&
<
Empty
description=
"暂无数据"
/>
}
</
Card
>
</
Col
>
<
Col
span=
{
12
}
>
<
Card
title=
"各场景调用量 TOP 10"
size=
"small"
>
{
(
aiStats
?.
scene_counts
??
[]).
map
((
item
:
{
scene
:
string
;
count
:
number
},
idx
:
number
)
=>
(
<
div
key=
{
item
.
scene
}
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
space-between
'
,
padding
:
'
4px 0
'
,
borderBottom
:
'
1px solid #f0f0f0
'
}
}
>
<
Text
>
{
idx
+
1
}
.
{
item
.
scene
}
</
Text
>
<
Tag
color=
"green"
>
{
item
.
count
}
</
Tag
>
</
div
>
))
}
{
(
aiStats
?.
scene_counts
??
[]).
length
===
0
&&
<
Empty
description=
"暂无数据"
/>
}
</
Card
>
</
Col
>
</
Row
>
),
},
]
}
/>
</
Card
>
{
/* 执行日志详情 Modal */
}
<
Modal
title=
{
<
Space
><
HistoryOutlined
/>
执行日志详情
</
Space
>
}
open=
{
!!
expandedLog
}
onCancel=
{
()
=>
setExpandedLog
(
null
)
}
footer=
{
null
}
width=
{
700
}
>
{
expandedLog
&&
(()
=>
{
let
toolCalls
:
ToolCall
[]
=
[];
try
{
toolCalls
=
JSON
.
parse
(
expandedLog
.
tool_calls
||
'
[]
'
);
}
catch
{}
let
inputObj
:
Record
<
string
,
unknown
>
=
{};
try
{
inputObj
=
JSON
.
parse
(
expandedLog
.
input
||
'
{}
'
);
}
catch
{}
let
outputObj
:
Record
<
string
,
unknown
>
=
{};
try
{
outputObj
=
JSON
.
parse
(
expandedLog
.
output
||
'
{}
'
);
}
catch
{}
return
(
<
div
style=
{
{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
12
}
}
>
<
div
style=
{
{
display
:
'
grid
'
,
gridTemplateColumns
:
'
1fr 1fr
'
,
gap
:
8
,
fontSize
:
13
}
}
>
<
div
><
Text
type=
"secondary"
>
智能体:
</
Text
><
Tag
>
{
expandedLog
.
agent_id
}
</
Tag
></
div
>
<
div
><
Text
type=
"secondary"
>
状态:
</
Text
><
Badge
status=
{
expandedLog
.
success
?
'
success
'
:
'
error
'
}
text=
{
expandedLog
.
success
?
'
成功
'
:
'
失败
'
}
/></
div
>
<
div
><
Text
type=
"secondary"
>
迭代次数:
</
Text
>
{
expandedLog
.
iterations
}
</
div
>
<
div
><
Text
type=
"secondary"
>
耗时:
</
Text
>
{
expandedLog
.
duration_ms
}
ms
</
div
>
<
div
><
Text
type=
"secondary"
>
Tokens:
</
Text
>
{
expandedLog
.
total_tokens
}
</
div
>
<
div
><
Text
type=
"secondary"
>
完成原因:
</
Text
>
{
expandedLog
.
finish_reason
||
'
-
'
}
</
div
>
</
div
>
<
Card
size=
"small"
title=
"用户输入"
>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
(
inputObj
.
message
as
string
)
||
expandedLog
.
input
}
</
Text
>
</
Card
>
<
Card
size=
"small"
title=
"AI 回复"
>
<
Text
style=
{
{
fontSize
:
12
}
}
>
{
(
outputObj
.
response
as
string
)
||
expandedLog
.
output
}
</
Text
>
</
Card
>
{
renderToolCalls
(
toolCalls
)
}
</
div
>
);
})()
}
</
Modal
>
{
/* 链路追踪详情弹窗 */
}
<
Modal
title=
{
`链路追踪: ${selectedTraceID?.slice(0, 20)}
...
`
}
open=
{
traceModalOpen
}
onCancel=
{
()
=>
{
setTraceModalOpen
(
false
);
setSelectedTraceID
(
''
);
}
}
footer=
{
<
Button
onClick=
{
()
=>
setTraceModalOpen
(
false
)
}
>
关闭
</
Button
>
}
width=
{
700
}
>
{
traceFetching
?
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
40
}
}
><
Spin
/></
div
>
)
:
traceDetail
?
(
<
div
>
<
Card
size=
"small"
title=
"LLM 调用序列"
style=
{
{
marginBottom
:
16
}
}
>
<
Timeline
items=
{
(
traceDetail
.
llm_calls
??
[]).
map
((
log
:
AIUsageLog
)
=>
({
color
:
log
.
success
?
'
green
'
:
'
red
'
,
children
:
(
<
div
>
<
Space
>
<
Tag
color=
"purple"
>
迭代 #
{
log
.
iteration
}
</
Tag
>
<
Tag
>
{
log
.
scene
}
</
Tag
>
<
Text
type=
"secondary"
>
{
log
.
total_tokens
}
tokens
</
Text
>
<
Text
type=
"secondary"
>
{
log
.
response_time_ms
}
ms
</
Text
>
</
Space
>
</
div
>
),
}))
}
/>
{
(
traceDetail
.
llm_calls
??
[]).
length
===
0
&&
<
Empty
description=
"无 LLM 调用记录"
/>
}
</
Card
>
<
Card
size=
"small"
title=
"工具调用序列"
>
<
Timeline
items=
{
(
traceDetail
.
tool_calls
??
[]).
map
((
tc
)
=>
({
color
:
tc
.
success
?
'
blue
'
:
'
red
'
,
children
:
(
<
div
>
<
Space
>
<
Tag
color=
"blue"
>
迭代 #
{
tc
.
iteration
}
</
Tag
>
<
Tag
color=
"cyan"
>
{
tc
.
tool_name
}
</
Tag
>
<
Tag
color=
{
tc
.
success
?
'
green
'
:
'
red
'
}
>
{
tc
.
success
?
'
成功
'
:
'
失败
'
}
</
Tag
>
<
Text
type=
"secondary"
>
{
tc
.
duration_ms
}
ms
</
Text
>
</
Space
>
</
div
>
),
}))
}
/>
{
(
traceDetail
.
tool_calls
??
[]).
length
===
0
&&
<
Empty
description=
"无工具调用记录"
/>
}
</
Card
>
</
div
>
)
:
(
<
Empty
description=
"未找到追踪数据"
/>
)
}
</
Modal
>
</
div
>
);
}
web/src/app/(main)/admin/layout.tsx
View file @
04584395
'
use client
'
;
import
React
,
{
Suspense
,
useState
,
useEffect
,
useMemo
,
useCallback
}
from
'
react
'
;
import
{
useRouter
,
usePathname
,
useSearchParams
}
from
'
next/navigation
'
;
import
{
Layout
,
Menu
,
Avatar
,
Dropdown
,
Badge
,
Space
,
Typography
,
Tag
,
Spin
,
Popover
,
List
}
from
'
antd
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
useRouter
,
usePathname
}
from
'
next/navigation
'
;
import
{
Layout
,
Menu
,
Avatar
,
Dropdown
,
Badge
,
Space
,
Typography
,
Tag
}
from
'
antd
'
;
import
{
DashboardOutlined
,
UserOutlined
,
TeamOutlined
,
ApartmentOutlined
,
SettingOutlined
,
LogoutOutlined
,
BellOutlined
,
MedicineBoxOutlined
,
FileSearchOutlined
,
FileTextOutlined
,
RobotOutlined
,
SafetyCertificateOutlined
,
ApiOutlined
,
DeploymentUnitOutlined
,
BookOutlined
,
CheckCircleOutlined
,
SafetyOutlined
,
FundOutlined
,
AppstoreOutlined
,
ShopOutlined
,
MenuFoldOutlined
,
MenuUnfoldOutlined
,
CompassOutlined
,
ToolOutlined
,
AuditOutlined
,
ScheduleOutlined
,
SafetyOutlined
,
FundOutlined
,
AppstoreOutlined
,
MenuFoldOutlined
,
MenuUnfoldOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useUserStore
}
from
'
@/store/userStore
'
;
import
type
{
Menu
as
MenuType
}
from
'
@/api/rbac
'
;
import
{
myMenuApi
}
from
'
@/api/rbac
'
;
import
{
notificationApi
,
type
Notification
}
from
'
@/api/notification
'
;
const
{
Sider
,
Content
}
=
Layout
;
const
{
Text
}
=
Typography
;
// 图标名称 → React 图标组件映射
const
ICON_MAP
:
Record
<
string
,
React
.
ReactNode
>
=
{
DashboardOutlined
:
<
DashboardOutlined
/>,
UserOutlined
:
<
UserOutlined
/>,
TeamOutlined
:
<
TeamOutlined
/>,
ApartmentOutlined
:
<
ApartmentOutlined
/>,
SettingOutlined
:
<
SettingOutlined
/>,
MedicineBoxOutlined
:
<
MedicineBoxOutlined
/>,
FileSearchOutlined
:
<
FileSearchOutlined
/>,
FileTextOutlined
:
<
FileTextOutlined
/>,
RobotOutlined
:
<
RobotOutlined
/>,
SafetyCertificateOutlined
:
<
SafetyCertificateOutlined
/>,
ApiOutlined
:
<
ApiOutlined
/>,
DeploymentUnitOutlined
:
<
DeploymentUnitOutlined
/>,
BookOutlined
:
<
BookOutlined
/>,
CheckCircleOutlined
:
<
CheckCircleOutlined
/>,
SafetyOutlined
:
<
SafetyOutlined
/>,
FundOutlined
:
<
FundOutlined
/>,
AppstoreOutlined
:
<
AppstoreOutlined
/>,
ShopOutlined
:
<
ShopOutlined
/>,
CompassOutlined
:
<
CompassOutlined
/>,
ToolOutlined
:
<
ToolOutlined
/>,
AuditOutlined
:
<
AuditOutlined
/>,
ScheduleOutlined
:
<
ScheduleOutlined
/>,
BellOutlined
:
<
BellOutlined
/>,
};
// 将数据库菜单树转换为 Ant Design Menu items
function
convertMenuTree
(
menus
:
MenuType
[]):
any
[]
{
return
menus
.
map
(
m
=>
{
const
item
:
any
=
{
key
:
m
.
path
||
`menu-
${
m
.
id
}
`
,
label
:
m
.
name
,
icon
:
ICON_MAP
[
m
.
icon
]
||
null
,
};
if
(
m
.
children
&&
m
.
children
.
length
>
0
)
{
item
.
children
=
convertMenuTree
(
m
.
children
);
}
return
item
;
});
}
// 从菜单树中收集所有叶子节点路径
function
collectLeafPaths
(
menus
:
MenuType
[]):
string
[]
{
const
paths
:
string
[]
=
[];
for
(
const
m
of
menus
)
{
if
(
m
.
children
&&
m
.
children
.
length
>
0
)
{
paths
.
push
(...
collectLeafPaths
(
m
.
children
));
}
else
if
(
m
.
path
)
{
paths
.
push
(
m
.
path
);
}
}
return
paths
;
}
// 从菜单树中查找包含某路径的父节点key
function
findOpenKeys
(
menus
:
MenuType
[],
targetPath
:
string
):
string
[]
{
const
keys
:
string
[]
=
[];
for
(
const
m
of
menus
)
{
if
(
m
.
children
&&
m
.
children
.
length
>
0
)
{
const
childPaths
=
collectLeafPaths
(
m
.
children
);
if
(
childPaths
.
some
(
p
=>
targetPath
.
startsWith
(
p
)))
{
keys
.
push
(
m
.
path
||
`menu-
${
m
.
id
}
`
);
}
keys
.
push
(...
findOpenKeys
(
m
.
children
,
targetPath
));
}
}
return
keys
;
}
const
menuItems
=
[
{
key
:
'
/admin/dashboard
'
,
icon
:
<
DashboardOutlined
/>,
label
:
'
运营大盘
'
},
{
key
:
'
user-mgmt
'
,
icon
:
<
TeamOutlined
/>,
label
:
'
用户管理
'
,
children
:
[
{
key
:
'
/admin/patients
'
,
icon
:
<
UserOutlined
/>,
label
:
'
患者管理
'
},
{
key
:
'
/admin/doctors
'
,
icon
:
<
MedicineBoxOutlined
/>,
label
:
'
医生管理
'
},
{
key
:
'
/admin/admins
'
,
icon
:
<
SettingOutlined
/>,
label
:
'
管理员管理
'
},
],
},
{
key
:
'
/admin/departments
'
,
icon
:
<
ApartmentOutlined
/>,
label
:
'
科室管理
'
},
{
key
:
'
/admin/consultations
'
,
icon
:
<
FileSearchOutlined
/>,
label
:
'
问诊管理
'
},
{
key
:
'
/admin/prescription
'
,
icon
:
<
FileTextOutlined
/>,
label
:
'
处方监管
'
},
{
key
:
'
/admin/pharmacy
'
,
icon
:
<
MedicineBoxOutlined
/>,
label
:
'
药品库
'
},
{
key
:
'
/admin/ai-config
'
,
icon
:
<
RobotOutlined
/>,
label
:
'
AI配置
'
},
{
key
:
'
/admin/compliance
'
,
icon
:
<
SafetyCertificateOutlined
/>,
label
:
'
合规报表
'
},
{
key
:
'
ai-platform
'
,
icon
:
<
ApiOutlined
/>,
label
:
'
智能体平台
'
,
children
:
[
{
key
:
'
/admin/agents
'
,
icon
:
<
RobotOutlined
/>,
label
:
'
智能体管理
'
},
{
key
:
'
/admin/ai-logs
'
,
icon
:
<
FundOutlined
/>,
label
:
'
AI日志
'
},
{
key
:
'
/admin/workflows
'
,
icon
:
<
DeploymentUnitOutlined
/>,
label
:
'
工作流
'
},
{
key
:
'
/admin/tasks
'
,
icon
:
<
CheckCircleOutlined
/>,
label
:
'
人工审核
'
},
{
key
:
'
/admin/knowledge
'
,
icon
:
<
BookOutlined
/>,
label
:
'
知识库
'
},
{
key
:
'
/admin/safety
'
,
icon
:
<
SafetyOutlined
/>,
label
:
'
内容安全
'
},
],
},
{
key
:
'
system-mgmt
'
,
icon
:
<
SafetyCertificateOutlined
/>,
label
:
'
系统管理
'
,
children
:
[
{
key
:
'
/admin/roles
'
,
icon
:
<
SafetyOutlined
/>,
label
:
'
角色管理
'
},
{
key
:
'
/admin/menus
'
,
icon
:
<
AppstoreOutlined
/>,
label
:
'
菜单管理
'
},
],
},
];
export
default
function
AdminLayout
({
children
}:
{
children
:
React
.
ReactNode
})
{
return
(
<
Suspense
fallback=
{
<
div
style=
{
{
minHeight
:
'
100vh
'
,
background
:
'
#f5f6fa
'
}
}
/>
}
>
<
AdminLayoutInner
>
{
children
}
</
AdminLayoutInner
>
</
Suspense
>
);
}
function
AdminLayoutInner
({
children
}:
{
children
:
React
.
ReactNode
})
{
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
searchParams
=
useSearchParams
();
const
isEmbed
=
searchParams
?.
get
(
'
embed
'
)
===
'
1
'
;
const
{
user
,
logout
,
menus
,
setMenus
}
=
useUserStore
();
const
{
user
,
logout
}
=
useUserStore
();
const
[
collapsed
,
setCollapsed
]
=
useState
(
false
);
const
[
unreadCount
,
setUnreadCount
]
=
useState
(
0
);
const
[
notifications
,
setNotifications
]
=
useState
<
Notification
[]
>
([]);
const
currentPath
=
pathname
||
''
;
// 菜单为空时主动拉取
useEffect
(()
=>
{
if
(
!
user
||
menus
.
length
>
0
)
return
;
myMenuApi
.
getMenus
().
then
(
res
=>
{
if
(
res
.
data
&&
res
.
data
.
length
>
0
)
setMenus
(
res
.
data
);
}).
catch
(()
=>
{});
},
[
user
,
menus
.
length
,
setMenus
]);
// 监听 AI 助手导航事件
useEffect
(()
=>
{
if
(
!
user
)
return
;
const
fetchUnread
=
()
=>
{
notificationApi
.
getUnreadCount
().
then
(
res
=>
setUnreadCount
(
res
.
data
?.
count
||
0
)).
catch
(()
=>
{});
};
fetchUnread
();
const
timer
=
setInterval
(
fetchUnread
,
60000
);
return
()
=>
clearInterval
(
timer
);
},
[
user
]);
const
handleBellClick
=
()
=>
{
notificationApi
.
list
({
page
:
1
,
page_size
:
5
}).
then
(
res
=>
setNotifications
(
res
.
data
?.
list
||
[])).
catch
(()
=>
{});
};
const
handleMarkAllRead
=
()
=>
{
notificationApi
.
markAllRead
().
then
(()
=>
{
setUnreadCount
(
0
);
setNotifications
(
n
=>
n
.
map
(
i
=>
({
...
i
,
is_read
:
true
})));
}).
catch
(()
=>
{});
};
// 转换为 Ant Design menu items(直接使用store中的菜单数据)
const
menuItems
=
useMemo
(()
=>
convertMenuTree
(
menus
),
[
menus
]);
// 监听 AI 助手导航事件(embed 模式下跳过,避免 iframe 内拦截)
useEffect
(()
=>
{
if
(
isEmbed
)
return
;
const
handleAIAction
=
(
e
:
Event
)
=>
{
const
detail
=
(
e
as
CustomEvent
).
detail
;
if
(
detail
?.
action
===
'
navigate
'
&&
typeof
detail
.
page
===
'
string
'
)
{
...
...
@@ -151,7 +71,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
};
window
.
addEventListener
(
'
ai-action
'
,
handleAIAction
);
return
()
=>
window
.
removeEventListener
(
'
ai-action
'
,
handleAIAction
);
},
[
router
,
isEmbed
]);
},
[
router
]);
const
userMenuItems
=
[
{
key
:
'
profile
'
,
icon
:
<
UserOutlined
/>,
label
:
'
个人信息
'
},
...
...
@@ -168,23 +88,35 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
const
handleUserMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
===
'
logout
'
)
{
logout
();
router
.
push
(
'
/login
'
);
}
else
if
(
key
===
'
profile
'
||
key
===
'
settings
'
)
{
router
.
push
(
'
/admin/dashboard
'
);
}
};
const
getSelectedKeys
=
useCallback
(()
=>
{
const
allPaths
=
collectLeafPaths
(
menus
);
const
match
=
allPaths
.
find
(
k
=>
currentPath
.
startsWith
(
k
));
const
getSelectedKeys
=
()
=>
{
const
allKeys
=
[
'
/admin/dashboard
'
,
'
/admin/patients
'
,
'
/admin/doctors
'
,
'
/admin/admins
'
,
'
/admin/departments
'
,
'
/admin/consultations
'
,
'
/admin/prescription
'
,
'
/admin/pharmacy
'
,
'
/admin/ai-config
'
,
'
/admin/compliance
'
,
'
/admin/agents
'
,
'
/admin/ai-logs
'
,
'
/admin/workflows
'
,
'
/admin/tasks
'
,
'
/admin/knowledge
'
,
'
/admin/safety
'
,
'
/admin/roles
'
,
'
/admin/menus
'
];
const
match
=
allKeys
.
find
(
k
=>
currentPath
.
startsWith
(
k
));
return
match
?
[
match
]
:
[];
}
,
[
menus
,
currentPath
])
;
};
const
getOpenKeys
=
useCallback
(
()
=>
{
const
getOpenKeys
=
()
=>
{
if
(
collapsed
)
return
[];
return
findOpenKeys
(
menus
,
currentPath
);
},
[
menus
,
currentPath
,
collapsed
]);
if
(
isEmbed
)
{
return
<
div
style=
{
{
minHeight
:
'
100vh
'
,
background
:
'
#f5f6fa
'
}
}
>
{
children
}
</
div
>;
const
keys
:
string
[]
=
[];
if
([
'
/admin/patients
'
,
'
/admin/doctors
'
,
'
/admin/admins
'
].
some
(
k
=>
currentPath
.
startsWith
(
k
)))
{
keys
.
push
(
'
user-mgmt
'
);
}
if
([
'
/admin/agents
'
,
'
/admin/ai-logs
'
,
'
/admin/workflows
'
,
'
/admin/tasks
'
,
'
/admin/knowledge
'
,
'
/admin/safety
'
]
.
some
(
k
=>
currentPath
.
startsWith
(
k
)))
{
keys
.
push
(
'
ai-platform
'
);
}
if
([
'
/admin/roles
'
,
'
/admin/menus
'
].
some
(
k
=>
currentPath
.
startsWith
(
k
)))
{
keys
.
push
(
'
system-mgmt
'
);
}
return
keys
;
};
return
(
<
Layout
style=
{
{
minHeight
:
'
100vh
'
}
}
>
...
...
@@ -213,7 +145,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
>
<
div
style=
{
{
width
:
32
,
height
:
32
,
borderRadius
:
8
,
flexShrink
:
0
,
background
:
'
linear-gradient(135deg, #
722ed1 0%, #531dab
100%)
'
,
background
:
'
linear-gradient(135deg, #
0D9488 0%, #0F766E
100%)
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
}
}
>
<
MedicineBoxOutlined
style=
{
{
fontSize
:
16
,
color
:
'
#fff
'
}
}
/>
...
...
@@ -221,7 +153,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
{
!
collapsed
&&
(
<>
<
span
style=
{
{
fontSize
:
15
,
fontWeight
:
700
,
color
:
'
#1d2129
'
,
marginLeft
:
8
}
}
>
互联网医院
</
span
>
<
Tag
color=
"
purple
"
style=
{
{
marginLeft
:
6
,
fontSize
:
10
,
lineHeight
:
'
18px
'
,
padding
:
'
0 5px
'
}
}
>
管理
</
Tag
>
<
Tag
color=
"
cyan
"
style=
{
{
marginLeft
:
6
,
fontSize
:
10
,
lineHeight
:
'
18px
'
,
padding
:
'
0 5px
'
}
}
>
管理
</
Tag
>
</>
)
}
</
div
>
...
...
@@ -254,37 +186,13 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
{
collapsed
?
<
MenuUnfoldOutlined
/>
:
<
MenuFoldOutlined
/>
}
</
div
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
16
}
}
>
<
Popover
trigger=
"click"
placement=
"bottomRight"
onOpenChange=
{
(
open
)
=>
{
if
(
open
)
handleBellClick
();
}
}
content=
{
<
div
style=
{
{
width
:
300
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
space-between
'
,
alignItems
:
'
center
'
,
marginBottom
:
8
}
}
>
<
span
style=
{
{
fontWeight
:
600
,
fontSize
:
14
}
}
>
通知
</
span
>
{
unreadCount
>
0
&&
<
a
onClick=
{
handleMarkAllRead
}
style=
{
{
fontSize
:
12
}
}
>
全部已读
</
a
>
}
</
div
>
<
List
size=
"small"
dataSource=
{
notifications
}
locale=
{
{
emptyText
:
'
暂无通知
'
}
}
renderItem=
{
(
item
)
=>
(
<
List
.
Item
style=
{
{
opacity
:
item
.
is_read
?
0.6
:
1
}
}
>
<
List
.
Item
.
Meta
title=
{
<
span
style=
{
{
fontSize
:
13
}
}
>
{
item
.
title
}
</
span
>
}
description=
{
<
span
style=
{
{
fontSize
:
12
}
}
>
{
item
.
content
}
</
span
>
}
/>
</
List
.
Item
>
)
}
/>
</
div
>
}
>
<
Badge
count=
{
unreadCount
}
size=
"small"
>
<
Badge
count=
{
2
}
size=
"small"
>
<
BellOutlined
style=
{
{
fontSize
:
16
,
color
:
'
#595959
'
,
cursor
:
'
pointer
'
}
}
/>
</
Badge
>
</
Popover
>
{
user
?
(
<
Dropdown
menu=
{
{
items
:
userMenuItems
,
onClick
:
handleUserMenuClick
}
}
>
<
Space
style=
{
{
cursor
:
'
pointer
'
}
}
>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#
722ed1
'
}
}
/>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#
0D9488
'
}
}
/>
<
Text
style=
{
{
color
:
'
#1d2129
'
,
fontSize
:
13
}
}
>
{
user
.
real_name
||
'
管理员
'
}
</
Text
>
</
Space
>
</
Dropdown
>
...
...
@@ -292,7 +200,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
</
div
>
</
header
>
<
Content
style=
{
{
minHeight
:
'
calc(100vh - 56px)
'
,
background
:
'
#
f5f6fa
'
}
}
>
<
Content
style=
{
{
minHeight
:
'
calc(100vh - 56px)
'
,
background
:
'
#
F8FAFB
'
}
}
>
{
children
}
</
Content
>
</
Layout
>
...
...
web/src/app/(main)/admin/menus/page.tsx
View file @
04584395
...
...
@@ -3,11 +3,18 @@
import
React
,
{
useEffect
,
useState
,
useCallback
,
useMemo
}
from
'
react
'
;
import
{
Card
,
Button
,
Modal
,
Tree
,
Tag
,
Space
,
Spin
,
App
,
Dropdown
,
}
from
'
antd
'
;
import
{
AppstoreOutlined
,
ReloadOutlined
,
SaveOutlined
,
CheckSquareOutlined
,
MinusSquareOutlined
,
PlusOutlined
,
EditOutlined
,
DeleteOutlined
,
EyeOutlined
,
EyeInvisibleOutlined
,
MoreOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
DrawerForm
,
ProFormText
,
ProFormDigit
,
ProFormSelect
,
ProFormSwitch
,
ProFormTreeSelect
,
}
from
'
@ant-design/pro-components
'
;
import
{
menuApi
,
roleApi
}
from
'
@/api/rbac
'
;
import
type
{
Menu
,
Role
}
from
'
@/api/rbac
'
;
...
...
@@ -17,22 +24,66 @@ type TreeDataNode = {
children
?:
TreeDataNode
[];
};
// 将菜单树转为 Tree 组件的 treeData
function
menusToTreeData
(
menus
:
Menu
[]):
TreeDataNode
[]
{
// 菜单类型选项
const
menuTypeOptions
=
[
{
label
:
'
目录
'
,
value
:
'
directory
'
},
{
label
:
'
菜单
'
,
value
:
'
menu
'
},
{
label
:
'
按钮
'
,
value
:
'
button
'
},
];
// 将菜单树转为 Tree 组件的 treeData(带操作按钮)
function
menusToTreeData
(
menus
:
Menu
[],
onEdit
:
(
m
:
Menu
)
=>
void
,
onDelete
:
(
m
:
Menu
)
=>
void
,
onAddChild
:
(
m
:
Menu
)
=>
void
,
):
TreeDataNode
[]
{
return
menus
.
map
((
m
)
=>
({
key
:
m
.
id
,
title
:
(
<
span
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
space-between
'
,
width
:
'
100%
'
,
paddingRight
:
8
}
}
>
<
span
style=
{
{
flex
:
1
}
}
>
{
m
.
name
}
{
m
.
path
&&
<
span
style=
{
{
color
:
'
#999
'
,
fontSize
:
12
,
marginLeft
:
8
}
}
>
{
m
.
path
}
</
span
>
}
{
!
m
.
visible
&&
<
Tag
color=
"default"
style=
{
{
marginLeft
:
6
,
fontSize
:
11
}
}
>
隐藏
</
Tag
>
}
{
m
.
type
&&
<
Tag
color=
{
m
.
type
===
'
directory
'
?
'
blue
'
:
m
.
type
===
'
button
'
?
'
orange
'
:
'
green
'
}
style=
{
{
marginLeft
:
6
,
fontSize
:
11
}
}
>
{
m
.
type
===
'
directory
'
?
'
目录
'
:
m
.
type
===
'
button
'
?
'
按钮
'
:
'
菜单
'
}
</
Tag
>
}
{
!
m
.
visible
&&
<
Tag
color=
"default"
style=
{
{
marginLeft
:
6
,
fontSize
:
11
}
}
><
EyeInvisibleOutlined
/>
隐藏
</
Tag
>
}
</
span
>
<
Space
size=
{
4
}
style=
{
{
flexShrink
:
0
}
}
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
Dropdown
menu=
{
{
items
:
[
{
key
:
'
addChild
'
,
icon
:
<
PlusOutlined
/>,
label
:
'
添加子菜单
'
},
{
key
:
'
edit
'
,
icon
:
<
EditOutlined
/>,
label
:
'
编辑
'
},
{
type
:
'
divider
'
},
{
key
:
'
delete
'
,
icon
:
<
DeleteOutlined
/>,
label
:
'
删除
'
,
danger
:
true
},
],
onClick
:
({
key
})
=>
{
if
(
key
===
'
edit
'
)
onEdit
(
m
);
else
if
(
key
===
'
delete
'
)
onDelete
(
m
);
else
if
(
key
===
'
addChild
'
)
onAddChild
(
m
);
},
}
}
trigger=
{
[
'
click
'
]
}
>
<
Button
type=
"text"
size=
"small"
icon=
{
<
MoreOutlined
/>
}
style=
{
{
opacity
:
0.6
}
}
/>
</
Dropdown
>
</
Space
>
</
div
>
),
children
:
m
.
children
?.
length
?
menusToTreeData
(
m
.
children
)
:
undefined
,
children
:
m
.
children
?.
length
?
menusToTreeData
(
m
.
children
,
onEdit
,
onDelete
,
onAddChild
)
:
undefined
,
}));
}
// 收集树中所有叶节点 key(用于全选逻辑中正确处理 checkStrictly=false 模式)
// 将菜单树转为 TreeSelect 的 treeData
function
menusToSelectData
(
menus
:
Menu
[]):
any
[]
{
return
menus
.
map
((
m
)
=>
({
value
:
m
.
id
,
title
:
m
.
name
,
children
:
m
.
children
?.
length
?
menusToSelectData
(
m
.
children
)
:
undefined
,
}));
}
// 收集树中所有 key
function
collectAllKeys
(
menus
:
Menu
[]):
number
[]
{
const
keys
:
number
[]
=
[];
for
(
const
m
of
menus
)
{
...
...
@@ -44,6 +95,18 @@ function collectAllKeys(menus: Menu[]): number[] {
return
keys
;
}
// 在菜单树中查找指定 id 的菜单
function
findMenuById
(
menus
:
Menu
[],
id
:
number
):
Menu
|
undefined
{
for
(
const
m
of
menus
)
{
if
(
m
.
id
===
id
)
return
m
;
if
(
m
.
children
?.
length
)
{
const
found
=
findMenuById
(
m
.
children
,
id
);
if
(
found
)
return
found
;
}
}
return
undefined
;
}
export
default
function
MenusPage
()
{
const
{
message
}
=
App
.
useApp
();
const
[
roles
,
setRoles
]
=
useState
<
Role
[]
>
([]);
...
...
@@ -54,8 +117,57 @@ export default function MenusPage() {
const
[
menuLoading
,
setMenuLoading
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
// Menu CRUD state
const
[
addVisible
,
setAddVisible
]
=
useState
(
false
);
const
[
editVisible
,
setEditVisible
]
=
useState
(
false
);
const
[
editingMenu
,
setEditingMenu
]
=
useState
<
Menu
|
null
>
(
null
);
const
[
defaultParentId
,
setDefaultParentId
]
=
useState
<
number
|
undefined
>
(
undefined
);
const
allMenuKeys
=
useMemo
(()
=>
collectAllKeys
(
menus
),
[
menus
]);
const
treeData
=
useMemo
(()
=>
menusToTreeData
(
menus
),
[
menus
]);
const
parentTreeData
=
useMemo
(()
=>
menusToSelectData
(
menus
),
[
menus
]);
const
handleEdit
=
useCallback
((
m
:
Menu
)
=>
{
setEditingMenu
(
m
);
setEditVisible
(
true
);
},
[]);
const
handleDelete
=
useCallback
((
m
:
Menu
)
=>
{
Modal
.
confirm
({
title
:
'
确认删除
'
,
content
:
`确定要删除菜单「
${
m
.
name
}
」吗?子菜单将一并删除,该操作不可恢复。`
,
okType
:
'
danger
'
,
onOk
:
async
()
=>
{
try
{
await
menuApi
.
delete
(
m
.
id
);
message
.
success
(
'
已删除
'
);
fetchMenus
();
}
catch
{
message
.
error
(
'
删除失败
'
);
}
},
});
},
[]);
const
handleAddChild
=
useCallback
((
m
:
Menu
)
=>
{
setDefaultParentId
(
m
.
id
);
setAddVisible
(
true
);
},
[]);
const
treeData
=
useMemo
(
()
=>
menusToTreeData
(
menus
,
handleEdit
,
handleDelete
,
handleAddChild
),
[
menus
,
handleEdit
,
handleDelete
,
handleAddChild
],
);
const
fetchMenus
=
useCallback
(
async
()
=>
{
setMenuLoading
(
true
);
try
{
const
res
=
await
menuApi
.
listTree
();
setMenus
(
res
.
data
||
[]);
}
catch
{
message
.
error
(
'
加载菜单失败
'
);
}
setMenuLoading
(
false
);
},
[]);
// 加载角色列表和菜单树
const
fetchInitial
=
useCallback
(
async
()
=>
{
...
...
@@ -68,7 +180,6 @@ export default function MenusPage() {
const
roleList
=
rolesRes
.
data
||
[];
setRoles
(
roleList
);
setMenus
(
menusRes
.
data
||
[]);
// 默认选中第一个角色
if
(
roleList
.
length
>
0
&&
!
selectedRoleId
)
{
setSelectedRoleId
(
roleList
[
0
].
id
);
}
...
...
@@ -120,9 +231,69 @@ export default function MenusPage() {
setCheckedKeys
([]);
};
const
selectedRole
=
roles
.
find
((
r
)
=>
r
.
id
===
selectedRoleId
);
// 表单字段
const
menuFormFields
=
(
<>
<
ProFormText
name=
"name"
label=
"菜单名称"
rules=
{
[{
required
:
true
,
message
:
'
请输入菜单名称
'
}]
}
placeholder=
"请输入菜单名称"
/>
<
ProFormSelect
name=
"type"
label=
"菜单类型"
options=
{
menuTypeOptions
}
rules=
{
[{
required
:
true
,
message
:
'
请选择菜单类型
'
}]
}
placeholder=
"请选择菜单类型"
/>
<
ProFormTreeSelect
name=
"parent_id"
label=
"上级菜单"
placeholder=
"留空表示顶级菜单"
allowClear
fieldProps=
{
{
treeData
:
parentTreeData
,
treeDefaultExpandAll
:
true
,
}
}
/>
<
ProFormText
name=
"path"
label=
"路由路径"
placeholder=
"如 /admin/menus"
/>
<
ProFormText
name=
"icon"
label=
"图标"
placeholder=
"Ant Design 图标名称"
/>
<
ProFormText
name=
"component"
label=
"组件路径"
placeholder=
"前端组件路径(选填)"
/>
<
ProFormText
name=
"permission"
label=
"权限标识"
placeholder=
"如 admin:menu:list(选填)"
/>
<
ProFormDigit
name=
"sort"
label=
"排序号"
placeholder=
"数字越小越靠前"
min=
{
0
}
fieldProps=
{
{
style
:
{
width
:
'
100%
'
}
}
}
/>
<
ProFormSwitch
name=
"visible"
label=
"是否显示"
fieldProps=
{
{
checkedChildren
:
'
显示
'
,
unCheckedChildren
:
'
隐藏
'
}
}
/>
</>
);
return
(
<
div
style=
{
{
padding
:
'
20px 24px
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
16
}
}
>
{
/* Header */
}
...
...
@@ -132,11 +303,18 @@ export default function MenusPage() {
<
AppstoreOutlined
style=
{
{
marginRight
:
8
,
color
:
'
#1890ff
'
}
}
/>
菜单管理
</
h2
>
<
div
style=
{
{
fontSize
:
13
,
color
:
'
#8c8c8c
'
,
marginTop
:
2
}
}
>
选择角色,勾选
分配菜单权限
管理系统菜单结构,为角色
分配菜单权限
</
div
>
</
div
>
<
Space
>
<
Button
icon=
{
<
ReloadOutlined
/>
}
onClick=
{
fetchInitial
}
>
刷新
</
Button
>
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
{
setDefaultParentId
(
undefined
);
setAddVisible
(
true
);
}
}
>
添加菜单
</
Button
>
</
Space
>
</
div
>
...
...
@@ -225,6 +403,75 @@ export default function MenusPage() {
</
Card
>
</
div
>
</
Spin
>
{
/* Add Menu Drawer */
}
<
DrawerForm
title=
"添加菜单"
open=
{
addVisible
}
onOpenChange=
{
(
open
)
=>
{
setAddVisible
(
open
);
if
(
!
open
)
setDefaultParentId
(
undefined
);
}
}
width=
{
480
}
drawerProps=
{
{
placement
:
'
right
'
,
destroyOnClose
:
true
}
}
initialValues=
{
{
sort
:
0
,
visible
:
true
,
type
:
'
menu
'
,
parent_id
:
defaultParentId
}
}
onFinish=
{
async
(
values
)
=>
{
try
{
await
menuApi
.
create
({
...
values
,
parent_id
:
values
.
parent_id
||
0
,
});
message
.
success
(
'
菜单创建成功
'
);
fetchMenus
();
return
true
;
}
catch
{
message
.
error
(
'
操作失败
'
);
return
false
;
}
}
}
>
{
menuFormFields
}
</
DrawerForm
>
{
/* Edit Menu Drawer */
}
<
DrawerForm
title=
"编辑菜单"
open=
{
editVisible
}
onOpenChange=
{
(
open
)
=>
{
setEditVisible
(
open
);
if
(
!
open
)
setEditingMenu
(
null
);
}
}
width=
{
480
}
drawerProps=
{
{
placement
:
'
right
'
,
destroyOnClose
:
true
}
}
initialValues=
{
editingMenu
?
{
name
:
editingMenu
.
name
,
type
:
editingMenu
.
type
||
'
menu
'
,
parent_id
:
editingMenu
.
parent_id
||
undefined
,
path
:
editingMenu
.
path
,
icon
:
editingMenu
.
icon
,
component
:
editingMenu
.
component
,
permission
:
editingMenu
.
permission
,
sort
:
editingMenu
.
sort
,
visible
:
editingMenu
.
visible
,
}
:
undefined
}
onFinish=
{
async
(
values
)
=>
{
if
(
!
editingMenu
)
return
false
;
try
{
await
menuApi
.
update
(
editingMenu
.
id
,
{
...
values
,
parent_id
:
values
.
parent_id
||
0
,
});
message
.
success
(
'
菜单更新成功
'
);
fetchMenus
();
return
true
;
}
catch
{
message
.
error
(
'
操作失败
'
);
return
false
;
}
}
}
>
{
menuFormFields
}
</
DrawerForm
>
</
div
>
);
}
web/src/app/(main)/admin/workflows/page.tsx
View file @
04584395
'
use client
'
;
import
{
useEffect
,
useState
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Button
,
Drawer
,
Form
,
Input
,
Select
,
message
,
Space
,
Badge
,
Popconfirm
}
from
'
antd
'
;
import
{
DrawerForm
,
ProFormText
,
ProFormTextArea
,
ProFormSelect
}
from
'
@ant-design/pro-components
'
;
import
{
DeploymentUnitOutlined
,
PlayCircleOutlined
,
PlusOutlined
,
EditOutlined
,
DeleteOutlined
}
from
'
@ant-design/icons
'
;
import
{
workflowApi
}
from
'
@/api/agent
'
;
import
VisualWorkflowEditor
from
'
@/components/workflow/VisualWorkflowEditor
'
;
interface
Workflow
{
id
:
number
;
workflow_id
:
string
;
name
:
string
;
description
:
string
;
category
:
string
;
status
:
string
;
version
:
number
;
definition
?:
string
;
}
const
statusColor
:
Record
<
string
,
'
success
'
|
'
warning
'
|
'
default
'
>
=
{
active
:
'
success
'
,
draft
:
'
warning
'
,
archived
:
'
default
'
,
};
const
statusLabel
:
Record
<
string
,
string
>
=
{
active
:
'
已启用
'
,
draft
:
'
草稿
'
,
archived
:
'
已归档
'
,
};
const
categoryLabel
:
Record
<
string
,
string
>
=
{
pre_consult
:
'
预问诊
'
,
diagnosis
:
'
诊断
'
,
prescription
:
'
处方审核
'
,
follow_up
:
'
随访
'
,
};
export
default
function
WorkflowsPage
()
{
const
[
workflows
,
setWorkflows
]
=
useState
<
Workflow
[]
>
([]);
const
[
createDrawer
,
setCreateDrawer
]
=
useState
(
false
);
const
[
editorDrawer
,
setEditorDrawer
]
=
useState
(
false
);
const
[
editingWorkflow
,
setEditingWorkflow
]
=
useState
<
Workflow
|
null
>
(
null
);
const
[
form
]
=
Form
.
useForm
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
tableLoading
,
setTableLoading
]
=
useState
(
false
);
const
fetchWorkflows
=
async
()
=>
{
setTableLoading
(
true
);
try
{
const
res
=
await
workflowApi
.
list
();
setWorkflows
((
res
.
data
as
Workflow
[])
||
[]);
}
catch
{}
finally
{
setTableLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchWorkflows
();
},
[]);
const
handleCreate
=
async
(
values
:
Record
<
string
,
string
>
)
=>
{
setLoading
(
true
);
try
{
const
definition
=
{
id
:
values
.
workflow_id
,
name
:
values
.
name
,
nodes
:
{
start
:
{
id
:
'
start
'
,
type
:
'
start
'
,
name
:
'
开始
'
,
config
:
{},
next_nodes
:
[
'
end
'
]
},
end
:
{
id
:
'
end
'
,
type
:
'
end
'
,
name
:
'
结束
'
,
config
:
{},
next_nodes
:
[]
},
},
edges
:
[{
id
:
'
e1
'
,
source_node
:
'
start
'
,
target_node
:
'
end
'
}],
};
await
workflowApi
.
create
({
workflow_id
:
values
.
workflow_id
,
name
:
values
.
name
,
description
:
values
.
description
,
category
:
values
.
category
,
definition
:
JSON
.
stringify
(
definition
),
});
message
.
success
(
'
创建成功
'
);
setCreateDrawer
(
false
);
form
.
resetFields
();
fetchWorkflows
();
}
catch
{
message
.
error
(
'
创建失败
'
);
}
finally
{
setLoading
(
false
);
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const
handleSaveWorkflow
=
useCallback
(
async
(
nodes
:
any
[],
edges
:
any
[])
=>
{
if
(
!
editingWorkflow
)
return
;
try
{
await
workflowApi
.
update
(
editingWorkflow
.
id
,
{
definition
:
JSON
.
stringify
({
nodes
,
edges
})
});
message
.
success
(
'
工作流已保存
'
);
fetchWorkflows
();
}
catch
{
message
.
error
(
'
保存失败
'
);
}
},
[
editingWorkflow
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const
handleExecuteFromEditor
=
useCallback
(
async
(
nodes
:
any
[],
edges
:
any
[])
=>
{
if
(
!
editingWorkflow
)
return
;
try
{
const
result
=
await
workflowApi
.
execute
(
editingWorkflow
.
workflow_id
,
{
workflow_data
:
{
nodes
,
edges
}
});
message
.
success
(
`执行已启动:
${
result
.
data
?.
execution_id
}
`);
} catch { message.error('执行失败'); }
}, [editingWorkflow]);
const handleExecute = async (workflowId: string) => {
try {
const result = await workflowApi.execute(workflowId);
message.success(`
执行已启动
:
$
{
result
.
data
?.
execution_id
}
`);
} catch { message.error('执行失败'); }
};
const getEditorInitialData = (): { nodes?: unknown[]; edges?: unknown[] } | undefined => {
if (!editingWorkflow?.definition) return undefined;
try { return JSON.parse(editingWorkflow.definition); } catch { return undefined; }
};
const columns = [
{
title: '工作流', key: 'info',
render: (_: unknown, r: Workflow) => (
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{r.workflow_id}</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{
title: '类别', dataIndex: 'category', key: 'category', width: 100,
render: (v: string) => <Tag color="blue">{categoryLabel[v] || v}</Tag>,
},
{ title: '版本', dataIndex: 'version', key: 'version', width: 70, render: (v: number) => `
v$
{
v
}
` },
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (v: string) => <Badge status={statusColor[v] || 'default'} text={statusLabel[v] || v} />,
},
{
title: '操作', key: 'action', width: 260,
render: (_: unknown, r: Workflow) => (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => { setEditingWorkflow(r); setEditorDrawer(true); }}>编辑</Button>
<Button type="link" size="small" icon={<PlayCircleOutlined />} disabled={r.status !== 'active'} onClick={() => handleExecute(r.workflow_id)}>执行</Button>
{r.status === 'draft' && (
<Button type="link" size="small" onClick={async () => {
await workflowApi.publish(r.id);
message.success('已激活');
fetchWorkflows();
}}>激活</Button>
)}
<Popconfirm title="确认删除?" onConfirm={async () => {
await workflowApi.delete(r.workflow_id);
message.success('删除成功');
fetchWorkflows();
}}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>工作流管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>设计和管理 AI 工作流,实现复杂业务流程自动化</div>
</div>
{/* 操作栏 */}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<DeploymentUnitOutlined style={{ color: '#8c8c8c' }} />
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {workflows.length} 个工作流</span>
<Button type="primary" icon={<PlusOutlined />} style={{ marginLeft: 'auto' }} onClick={() => setCreateDrawer(true)}>
新建工作流
</Button>
</div>
</Card>
{/* 工作流列表 */}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table dataSource={workflows} columns={columns} rowKey="id" loading={tableLoading} size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: (t) => `
共
$
{
t
}
条
` }}
/>
</Card>
{/* 新建工作流 DrawerForm */}
<DrawerForm
title="新建工作流"
open={createDrawer}
onOpenChange={(open) => { setCreateDrawer(open); if (!open) form.resetFields(); }}
onFinish={async (values) => { await handleCreate(values); return true; }}
drawerProps={{ placement: 'right', destroyOnClose: true }}
width={480}
loading={loading}
form={form}
>
<ProFormText name="workflow_id" label="工作流 ID" placeholder="如: smart_pre_consult"
rules={[{ required: true, message: '请输入工作流ID' }]} />
<ProFormText name="name" label="名称" placeholder="请输入工作流名称"
rules={[{ required: true, message: '请输入名称' }]} />
<ProFormTextArea name="description" label="描述" placeholder="请输入描述(选填)"
fieldProps={{ rows: 2 }} />
<ProFormSelect name="category" label="类别" placeholder="选择类别"
options={[
{ value: 'pre_consult', label: '预问诊' },
{ value: 'diagnosis', label: '诊断' },
{ value: 'prescription', label: '处方审核' },
{ value: 'follow_up', label: '随访' },
]} />
</DrawerForm>
{/* 可视化编辑器 Drawer */}
<Drawer
title={`
编辑工作流
·
$
{
editingWorkflow
?.
name
}
`}
open={editorDrawer}
onClose={() => { setEditorDrawer(false); setEditingWorkflow(null); }}
placement="right"
destroyOnClose
width={960}
>
<div style={{ height: 650 }}>
<VisualWorkflowEditor
workflowName={editingWorkflow?.name || '编辑工作流'}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialNodes={getEditorInitialData()?.nodes as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialEdges={getEditorInitialData()?.edges as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSave={handleSaveWorkflow as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onExecute={handleExecuteFromEditor as any}
/>
</div>
</Drawer>
</div>
);
}
import
PageComponent
from
'
@/pages/admin/Workflows
'
;
export
default
function
Page
()
{
return
<
PageComponent
/>;
}
web/src/config/routes.ts
View file @
04584395
...
...
@@ -95,6 +95,30 @@ export const ROUTE_REGISTRY: RouteDefinition[] = [
permissions
:
[
'
admin:pharmacy:list
'
],
operations
:
{
list
:
'
/admin/pharmacy
'
},
},
{
code
:
'
admin_users
'
,
path
:
'
/admin/users
'
,
name
:
'
用户管理
'
,
role
:
'
admin
'
,
permissions
:
[
'
admin:users:list
'
],
operations
:
{
list
:
'
/admin/users
'
},
},
{
code
:
'
admin_doctor_review
'
,
path
:
'
/admin/doctor-review
'
,
name
:
'
医生审核
'
,
role
:
'
admin
'
,
permissions
:
[
'
admin:doctor-review:list
'
],
operations
:
{
list
:
'
/admin/doctor-review
'
},
},
{
code
:
'
admin_statistics
'
,
path
:
'
/admin/statistics
'
,
name
:
'
数据统计
'
,
role
:
'
admin
'
,
permissions
:
[
'
admin:statistics:view
'
],
operations
:
{
list
:
'
/admin/statistics
'
},
},
// AI 管理
{
code
:
'
admin_ai_config
'
,
...
...
@@ -104,29 +128,21 @@ export const ROUTE_REGISTRY: RouteDefinition[] = [
permissions
:
[
'
admin:ai-config:view
'
],
operations
:
{
list
:
'
/admin/ai-config
'
},
},
{
code
:
'
admin_ai_center
'
,
path
:
'
/admin/ai-center
'
,
name
:
'
AI中心
'
,
role
:
'
admin
'
,
permissions
:
[
'
admin:ai-center:view
'
],
operations
:
{
list
:
'
/admin/ai-center
'
},
},
{
code
:
'
admin_agents
'
,
path
:
'
/admin/agents
'
,
name
:
'
Agent
管理
'
,
name
:
'
智能体
管理
'
,
role
:
'
admin
'
,
permissions
:
[
'
admin:agents:list
'
],
operations
:
{
list
:
'
/admin/agents
'
},
},
{
code
:
'
admin_
tool
s
'
,
path
:
'
/admin/
tool
s
'
,
name
:
'
工具管理
'
,
code
:
'
admin_
ai_log
s
'
,
path
:
'
/admin/
ai-log
s
'
,
name
:
'
AI日志
'
,
role
:
'
admin
'
,
permissions
:
[
'
admin:
tools:list
'
],
operations
:
{
list
:
'
/admin/
tool
s
'
},
permissions
:
[
'
admin:
ai-logs:view
'
],
operations
:
{
list
:
'
/admin/
ai-log
s
'
},
},
{
code
:
'
admin_workflows
'
,
...
...
web/src/pages/admin/Departments/index.tsx
View file @
04584395
...
...
@@ -2,28 +2,42 @@
import
React
,
{
useState
,
useEffect
,
useRef
}
from
'
react
'
;
import
{
useSearchParams
}
from
'
next/navigation
'
;
import
{
Typography
,
Space
,
Button
,
Modal
,
App
}
from
'
antd
'
;
import
{
PlusOutlined
,
EditOutlined
,
DeleteOutlined
}
from
'
@ant-design/icons
'
;
import
{
ProTable
,
DrawerForm
,
ProFormText
,
ProFormDigit
}
from
'
@ant-design/pro-components
'
;
import
{
Typography
,
Space
,
Button
,
Modal
,
Tag
,
App
}
from
'
antd
'
;
import
{
PlusOutlined
,
EditOutlined
,
DeleteOutlined
,
MedicineBoxOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
ProTable
,
DrawerForm
,
ProFormText
,
ProFormDigit
,
}
from
'
@ant-design/pro-components
'
;
import
type
{
ActionType
,
ProColumns
}
from
'
@ant-design/pro-components
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
import
type
{
Department
}
from
'
../../../api/doctor
'
;
const
{
Text
}
=
Typography
;
const
AdminDepartmentsPage
:
React
.
FC
=
()
=>
{
const
{
message
}
=
App
.
useApp
();
const
searchParams
=
useSearchParams
();
const
actionRef
=
useRef
<
ActionType
>
();
const
[
modalVisible
,
setModalVisible
]
=
useState
(
false
);
const
actionRef
=
useRef
<
ActionType
>
(
null
);
const
[
addModalVisible
,
setAddModalVisible
]
=
useState
(
false
);
const
[
editModalVisible
,
setEditModalVisible
]
=
useState
(
false
);
const
[
editingDept
,
setEditingDept
]
=
useState
<
Department
|
null
>
(
null
);
useEffect
(()
=>
{
if
(
searchParams
.
get
(
'
action
'
)
===
'
add
'
)
setModalVisible
(
true
);
if
(
searchParams
.
get
(
'
action
'
)
===
'
add
'
)
set
Add
ModalVisible
(
true
);
},
[
searchParams
]);
const
handleEdit
=
(
record
:
Department
)
=>
{
setEditingDept
(
record
);
setEditModalVisible
(
true
);
};
const
handleDelete
=
(
record
:
Department
)
=>
{
Modal
.
confirm
({
title
:
'
确认删除
'
,
content
:
`确定要删除科室「
${
record
.
name
}
」吗?该操作不可恢复。`
,
okType
:
'
danger
'
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
deleteDepartment
(
record
.
id
);
...
...
@@ -38,74 +52,192 @@ const AdminDepartmentsPage: React.FC = () => {
const
columns
:
ProColumns
<
Department
>
[]
=
[
{
title
:
'
图标
'
,
dataIndex
:
'
icon
'
,
width
:
60
,
render
:
(
_
,
record
)
=>
<
span
style=
{
{
fontSize
:
24
}
}
>
{
record
.
icon
||
'
🏥
'
}
</
span
>
,
title
:
'
关键词
'
,
dataIndex
:
'
keyword
'
,
hideInTable
:
true
,
fieldProps
:
{
placeholder
:
'
搜索科室名称
'
}
,
},
{
title
:
'
科室
名称
'
,
title
:
'
科室
'
,
dataIndex
:
'
name
'
,
render
:
(
_
,
record
)
=>
<
Typography
.
Text
strong
>
{
record
.
name
}
</
Typography
.
Text
>,
search
:
false
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
div
style=
{
{
width
:
40
,
height
:
40
,
borderRadius
:
8
,
background
:
'
linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%)
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
fontSize
:
20
,
}
}
>
{
record
.
icon
||
<
MedicineBoxOutlined
style=
{
{
color
:
'
#1890ff
'
}
}
/>
}
</
div
>
<
div
>
<
Text
strong
>
{
record
.
name
}
</
Text
>
{
record
.
parent_id
&&
(
<>
<
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
子科室
</
Text
>
</>
)
}
</
div
>
</
Space
>
),
},
{
title
:
'
排序
'
,
dataIndex
:
'
sort_order
'
,
search
:
false
,
width
:
80
,
render
:
(
v
)
=>
<
Tag
>
{
v
as
number
}
</
Tag
>,
},
{
title
:
'
子科室
'
,
dataIndex
:
'
children
'
,
width
:
100
,
search
:
false
,
render
:
(
_
,
record
)
=>
{
const
count
=
record
.
children
?.
length
||
0
;
return
count
>
0
?
<
Tag
color=
"blue"
>
{
count
}
个
</
Tag
>
:
<
Text
type=
"secondary"
>
-
</
Text
>;
},
},
{
title
:
'
操作
'
,
valueType
:
'
option
'
,
width
:
160
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
{
setEditingDept
(
record
);
setModalVisible
(
true
);
}
}
>
编辑
</
Button
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleDelete
(
record
)
}
>
删除
</
Button
>
<
Space
size=
{
0
}
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
handleEdit
(
record
)
}
>
编辑
</
Button
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleDelete
(
record
)
}
>
删除
</
Button
>
</
Space
>
),
},
];
return
(
const
formContent
=
(
<>
<
ProFormText
name=
"name"
label=
"科室名称"
rules=
{
[{
required
:
true
,
message
:
'
请输入科室名称
'
}]
}
placeholder=
"请输入科室名称"
/>
<
ProFormText
name=
"icon"
label=
"图标"
placeholder=
"请输入Emoji图标,如 🏥 🫀 🧠"
extra=
"支持 Emoji 表情,留空将显示默认图标"
/>
<
ProFormDigit
name=
"sort_order"
label=
"排序号"
placeholder=
"数字越小越靠前"
min=
{
1
}
fieldProps=
{
{
style
:
{
width
:
'
100%
'
}
}
}
extra=
"排序号越小,科室排列越靠前"
/>
</>
);
return
(
<
div
style=
{
{
padding
:
'
20px 24px
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
16
}
}
>
<
div
>
<
h2
style=
{
{
fontSize
:
20
,
fontWeight
:
700
,
color
:
'
#1d2129
'
,
margin
:
0
}
}
>
科室管理
</
h2
>
<
div
style=
{
{
fontSize
:
13
,
color
:
'
#8c8c8c
'
,
marginTop
:
2
}
}
>
管理医院科室分类与排序
</
div
>
</
div
>
<
ProTable
<
Department
>
headerTitle="科室管理"
tooltip="管理医院科室分类与排序"
headerTitle="科室列表"
rowKey="id"
actionRef=
{
actionRef
}
cardBordered
search=
{
false
}
search=
{
{
labelWidth
:
'
auto
'
,
optionRender
:
(
searchConfig
,
formProps
,
dom
)
=>
[
...
dom
,
<
Button
key=
"add"
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
setAddModalVisible
(
true
)
}
>
添加科室
</
Button
>,
],
}
}
options=
{
{
density
:
true
,
reload
:
true
,
setting
:
true
}
}
request=
{
async
()
=>
{
request=
{
async
(
params
)
=>
{
const
res
=
await
adminApi
.
getDepartmentList
();
return
{
data
:
res
.
data
||
[],
success
:
true
};
const
list
=
res
.
data
||
[];
// Flatten tree for display
const
rows
:
Department
[]
=
[];
const
flatten
=
(
items
:
Department
[])
=>
{
for
(
const
item
of
items
)
{
rows
.
push
(
item
);
if
(
item
.
children
?.
length
)
flatten
(
item
.
children
);
}
};
flatten
(
Array
.
isArray
(
list
)
?
list
:
[]);
// Client-side keyword filter
const
keyword
=
params
.
keyword
?.
trim
()?.
toLowerCase
();
const
filtered
=
keyword
?
rows
.
filter
((
d
)
=>
d
.
name
.
toLowerCase
().
includes
(
keyword
))
:
rows
;
return
{
data
:
filtered
,
success
:
true
,
total
:
filtered
.
length
};
}
}
pagination=
{
false
}
toolBarRender=
{
()
=>
[
<
Button
key=
"add"
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
{
setEditingDept
(
null
);
setModalVisible
(
true
);
}
}
>
添加科室
</
Button
>,
]
}
pagination=
{
{
defaultPageSize
:
20
,
showSizeChanger
:
true
,
showTotal
:
(
t
)
=>
`共 ${t} 个科室`
}
}
toolBarRender=
{
()
=>
[]
}
columns=
{
columns
}
/
>
{
/* Add Department */
}
<
DrawerForm
title=
"添加科室"
open=
{
addModalVisible
}
onOpenChange=
{
setAddModalVisible
}
initialValues=
{
{
sort_order
:
1
}
}
width=
{
480
}
drawerProps=
{
{
placement
:
'
right
'
,
destroyOnClose
:
true
}
}
onFinish=
{
async
(
values
)
=>
{
try
{
await
adminApi
.
createDepartment
(
values
);
message
.
success
(
'
科室创建成功
'
);
actionRef
.
current
?.
reload
();
return
true
;
}
catch
{
message
.
error
(
'
操作失败
'
);
return
false
;
}
}
}
>
{
formContent
}
</
DrawerForm
>
{
/* Edit Department */
}
<
DrawerForm
title=
{
editingDept
?
'
编辑科室
'
:
'
添加科室
'
}
open=
{
m
odalVisible
}
title=
"编辑科室"
open=
{
editM
odalVisible
}
onOpenChange=
{
(
open
)
=>
{
setModalVisible
(
open
);
set
Edit
ModalVisible
(
open
);
if
(
!
open
)
setEditingDept
(
null
);
}
}
initialValues=
{
editingDept
||
{
sort_order
:
1
}
}
width=
{
480
}
drawerProps=
{
{
placement
:
'
right
'
,
destroyOnClose
:
true
}
}
onFinish=
{
async
(
values
)
=>
{
if
(
!
editingDept
)
return
false
;
try
{
if
(
editingDept
)
{
await
adminApi
.
updateDepartment
(
editingDept
.
id
,
values
);
message
.
success
(
'
科室更新成功
'
);
}
else
{
await
adminApi
.
createDepartment
(
values
);
message
.
success
(
'
科室创建成功
'
);
}
actionRef
.
current
?.
reload
();
return
true
;
}
catch
{
...
...
@@ -114,11 +246,9 @@ const AdminDepartmentsPage: React.FC = () => {
}
}
}
>
<
ProFormText
name=
"name"
label=
"科室名称"
rules=
{
[{
required
:
true
,
message
:
'
请输入科室名称
'
}]
}
placeholder=
"请输入科室名称"
/>
<
ProFormText
name=
"icon"
label=
"图标"
placeholder=
"请输入Emoji图标"
/>
<
ProFormDigit
name=
"sort_order"
label=
"排序号"
min=
{
1
}
fieldProps=
{
{
style
:
{
width
:
'
100%
'
}
}
}
/>
{
formContent
}
</
DrawerForm
>
</>
</
div
>
);
};
...
...
web/src/pages/admin/Doctors/index.tsx
View file @
04584395
...
...
@@ -12,6 +12,7 @@ import {
}
from
'
@ant-design/icons
'
;
import
{
ProTable
,
DrawerForm
,
ProFormText
,
ProFormSelect
,
ProFormTextArea
,
ProFormDigit
,
}
from
'
@ant-design/pro-components
'
;
import
type
{
ActionType
,
ProColumns
}
from
'
@ant-design/pro-components
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
...
...
@@ -221,6 +222,16 @@ const AdminDoctorsPage: React.FC = () => {
width
:
150
,
ellipsis
:
true
,
},
{
title
:
'
问诊价格
'
,
dataIndex
:
'
price
'
,
search
:
false
,
width
:
90
,
render
:
(
_
,
record
)
=>
{
const
p
=
record
.
price
;
return
p
?
<
Text
style=
{
{
color
:
'
#1890ff
'
,
fontWeight
:
600
}
}
>
¥
{
(
p
/
100
).
toFixed
(
0
)
}
</
Text
>
:
<
Text
type=
"secondary"
>
未设置
</
Text
>;
},
},
{
title
:
'
认证状态
'
,
dataIndex
:
'
review_status
'
,
...
...
@@ -397,11 +408,20 @@ const AdminDoctorsPage: React.FC = () => {
placeholder=
"请输入执业证号(选填)"
colProps=
{
{
span
:
12
}
}
/>
<
ProFormDigit
name=
"price"
label=
"问诊价格(分)"
placeholder=
"例如 5000 = ¥50"
min=
{
0
}
fieldProps=
{
{
precision
:
0
}
}
rules=
{
[{
required
:
true
,
message
:
'
请输入问诊价格
'
}]
}
colProps=
{
{
span
:
12
}
}
/>
<
ProFormText
.
Password
name=
"password"
label=
"初始密码"
placeholder=
"默认密码:123456"
colProps=
{
{
span
:
24
}
}
colProps=
{
{
span
:
12
}
}
/>
<
ProFormTextArea
name=
"introduction"
...
...
@@ -432,6 +452,7 @@ const AdminDoctorsPage: React.FC = () => {
title
:
editingDoctor
.
title
,
department_id
:
editingDoctor
.
department_id
,
hospital
:
editingDoctor
.
hospital
,
price
:
editingDoctor
.
price
||
0
,
}
:
undefined
}
...
...
@@ -480,7 +501,15 @@ const AdminDoctorsPage: React.FC = () => {
name=
"hospital"
label=
"医院"
placeholder=
"请输入医院名称"
colProps=
{
{
span
:
24
}
}
colProps=
{
{
span
:
12
}
}
/>
<
ProFormDigit
name=
"price"
label=
"问诊价格(分)"
placeholder=
"例如 5000 = ¥50"
min=
{
0
}
fieldProps=
{
{
precision
:
0
}
}
colProps=
{
{
span
:
12
}
}
/>
</
DrawerForm
>
...
...
@@ -501,6 +530,9 @@ const AdminDoctorsPage: React.FC = () => {
<
Descriptions
.
Item
label=
"科室"
>
{
currentDoctor
.
department_name
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医院"
span=
{
2
}
>
{
currentDoctor
.
hospital
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"执业证号"
>
{
currentDoctor
.
license_no
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"问诊价格"
>
{
currentDoctor
.
price
?
`¥${(currentDoctor.price / 100).toFixed(0)}`
:
'
未设置
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"认证状态"
>
{
getStatusTag
(
currentDoctor
.
review_status
)
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"账号状态"
>
<
Tag
color=
{
currentDoctor
.
user_status
===
'
active
'
?
'
green
'
:
'
red
'
}
>
...
...
web/src/pages/admin/Workflows/index.tsx
0 → 100644
View file @
04584395
'
use client
'
;
import
React
,
{
useState
,
useRef
}
from
'
react
'
;
import
dynamic
from
'
next/dynamic
'
;
import
{
Button
,
Space
,
Tag
,
Badge
,
Drawer
,
Popconfirm
,
App
}
from
'
antd
'
;
import
{
PlayCircleOutlined
,
PlusOutlined
,
EditOutlined
,
DeleteOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
ProTable
,
DrawerForm
,
ProFormText
,
ProFormTextArea
,
ProFormSelect
,
}
from
'
@ant-design/pro-components
'
;
import
type
{
ActionType
,
ProColumns
}
from
'
@ant-design/pro-components
'
;
import
{
workflowApi
}
from
'
@/api/agent
'
;
const
VisualWorkflowEditor
=
dynamic
(
()
=>
import
(
'
@/components/workflow/VisualWorkflowEditor
'
),
{
ssr
:
false
},
);
interface
Workflow
{
id
:
number
;
workflow_id
:
string
;
name
:
string
;
description
:
string
;
category
:
string
;
status
:
string
;
version
:
number
;
definition
?:
string
;
}
const
statusColor
:
Record
<
string
,
'
success
'
|
'
warning
'
|
'
default
'
>
=
{
active
:
'
success
'
,
draft
:
'
warning
'
,
archived
:
'
default
'
,
};
const
statusLabel
:
Record
<
string
,
string
>
=
{
active
:
'
已启用
'
,
draft
:
'
草稿
'
,
archived
:
'
已归档
'
,
};
const
categoryLabel
:
Record
<
string
,
string
>
=
{
pre_consult
:
'
预问诊
'
,
consult_created
:
'
问诊创建
'
,
consult_ended
:
'
问诊结束
'
,
follow_up
:
'
随访
'
,
prescription_created
:
'
处方创建
'
,
prescription_approved
:
'
处方审核通过
'
,
payment_completed
:
'
支付完成
'
,
renewal_requested
:
'
续方申请
'
,
health_alert
:
'
健康预警
'
,
doctor_review
:
'
医生审核
'
,
};
// 后端 definition 格式转 ReactFlow 格式
function
convertDefinitionToReactFlow
(
definition
:
string
|
undefined
)
{
if
(
!
definition
)
return
undefined
;
try
{
const
def
=
JSON
.
parse
(
definition
);
let
nodeArray
:
unknown
[]
=
[];
if
(
def
.
nodes
&&
!
Array
.
isArray
(
def
.
nodes
))
{
const
entries
=
Object
.
values
(
def
.
nodes
)
as
Array
<
{
id
:
string
;
type
:
string
;
name
:
string
;
config
?:
Record
<
string
,
unknown
>
}
>
;
nodeArray
=
entries
.
map
((
n
,
i
)
=>
({
id
:
n
.
id
,
type
:
'
custom
'
,
position
:
{
x
:
250
,
y
:
50
+
i
*
120
},
data
:
{
label
:
n
.
name
,
nodeType
:
n
.
type
,
config
:
n
.
config
},
}));
}
else
if
(
Array
.
isArray
(
def
.
nodes
))
{
nodeArray
=
def
.
nodes
;
}
let
edgeArray
:
unknown
[]
=
[];
if
(
Array
.
isArray
(
def
.
edges
))
{
edgeArray
=
def
.
edges
.
map
((
e
:
{
id
:
string
;
source_node
?:
string
;
source
?:
string
;
target_node
?:
string
;
target
?:
string
})
=>
({
id
:
e
.
id
,
source
:
e
.
source_node
||
e
.
source
,
target
:
e
.
target_node
||
e
.
target
,
animated
:
true
,
style
:
{
stroke
:
'
#1890ff
'
},
}));
}
return
{
nodes
:
nodeArray
,
edges
:
edgeArray
};
}
catch
{
return
undefined
;
}
}
const
AdminWorkflowsPage
:
React
.
FC
=
()
=>
{
const
{
message
}
=
App
.
useApp
();
const
actionRef
=
useRef
<
ActionType
>
(
null
);
const
[
addModalVisible
,
setAddModalVisible
]
=
useState
(
false
);
const
[
editorDrawer
,
setEditorDrawer
]
=
useState
(
false
);
const
[
editingWorkflow
,
setEditingWorkflow
]
=
useState
<
Workflow
|
null
>
(
null
);
const
handleExecute
=
async
(
workflowId
:
string
)
=>
{
try
{
const
result
=
await
workflowApi
.
execute
(
workflowId
);
message
.
success
(
`执行已启动:
${
result
.
data
?.
execution_id
}
`);
} catch { message.error('执行失败'); }
};
const columns: ProColumns<Workflow>[] = [
{
title: '工作流', dataIndex: 'name',
render: (_, r) => (
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{r.workflow_id}</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', search: false, ellipsis: true },
{
title: '类别', dataIndex: 'category', width: 110,
valueEnum: Object.fromEntries(Object.entries(categoryLabel).map(([k, v]) => [k, { text: v }])),
render: (_, r) => <Tag color="blue">{categoryLabel[r.category] || r.category}</Tag>,
},
{ title: '版本', dataIndex: 'version', search: false, width: 70, render: (v) => `
v$
{
v
as
number
}
` },
{
title: '状态', dataIndex: 'status', width: 90,
valueEnum: { active: { text: '已启用', status: 'Success' }, draft: { text: '草稿', status: 'Warning' }, archived: { text: '已归档', status: 'Default' } },
render: (_, r) => <Badge status={statusColor[r.status] || 'default'} text={statusLabel[r.status] || r.status} />,
},
{
title: '操作', valueType: 'option', width: 260,
render: (_, r) => (
<Space size={0}>
<a onClick={() => { setEditingWorkflow(r); setEditorDrawer(true); }}><EditOutlined /> 编辑</a>
{r.status === 'active' && <a onClick={() => handleExecute(r.workflow_id)}><PlayCircleOutlined /> 执行</a>}
{r.status === 'draft' && (
<a onClick={async () => {
await workflowApi.publish(r.id);
message.success('已激活');
actionRef.current?.reload();
}}>激活</a>
)}
<Popconfirm title="确认删除?" onConfirm={async () => {
await workflowApi.delete(r.workflow_id);
message.success('删除成功');
actionRef.current?.reload();
}}>
<a style={{ color: '#ff4d4f' }}><DeleteOutlined /> 删除</a>
</Popconfirm>
</Space>
),
},
];
const editorData = editorDrawer && editingWorkflow ? convertDefinitionToReactFlow(editingWorkflow.definition) : undefined;
return (
<div style={{ padding: '20px 24px' }}>
<ProTable<Workflow>
headerTitle="工作流管理"
tooltip="设计和管理 AI 工作流,实现复杂业务流程自动化"
actionRef={actionRef}
rowKey="id"
columns={columns}
cardBordered
request={async () => {
const res = await workflowApi.list();
return {
data: (res.data as Workflow[]) || [],
total: (res.data as Workflow[])?.length || 0,
success: true,
};
}}
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (t) => `
共
$
{
t
}
条
` }}
search={{ labelWidth: 'auto' }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>
新建工作流
</Button>,
]}
/>
{/* 新建工作流 DrawerForm */}
<DrawerForm
title="新建工作流"
open={addModalVisible}
onOpenChange={setAddModalVisible}
width={480}
drawerProps={{ placement: 'right', destroyOnClose: true }}
submitter={{
searchConfig: { submitText: '创建', resetText: '取消' },
resetButtonProps: { onClick: () => setAddModalVisible(false) },
}}
onFinish={async (values) => {
try {
const definition = {
id: values.workflow_id, name: values.name,
nodes: {
start: { id: 'start', type: 'start', name: '开始', config: {}, next_nodes: ['end'] },
end: { id: 'end', type: 'end', name: '结束', config: {}, next_nodes: [] },
},
edges: [{ id: 'e1', source_node: 'start', target_node: 'end' }],
};
await workflowApi.create({
workflow_id: values.workflow_id,
name: values.name,
description: values.description,
category: values.category,
definition: JSON.stringify(definition),
});
message.success('创建成功');
actionRef.current?.reload();
return true;
} catch {
message.error('创建失败');
return false;
}
}}
>
<ProFormText name="workflow_id" label="工作流 ID" placeholder="如: smart_pre_consult"
rules={[{ required: true, message: '请输入工作流ID' }]} />
<ProFormText name="name" label="名称" placeholder="请输入工作流名称"
rules={[{ required: true, message: '请输入名称' }]} />
<ProFormTextArea name="description" label="描述" placeholder="请输入描述(选填)"
fieldProps={{ rows: 2 }} />
<ProFormSelect name="category" label="类别" placeholder="选择类别"
options={Object.entries(categoryLabel).map(([value, label]) => ({ value, label }))} />
</DrawerForm>
{/* 可视化编辑器 Drawer */}
<Drawer
title={`
编辑工作流
·
$
{
editingWorkflow
?.
name
||
''
}
`}
open={editorDrawer}
onClose={() => { setEditorDrawer(false); setEditingWorkflow(null); }}
placement="right"
destroyOnClose
width={960}
>
{editorData && (
<div style={{ height: 650 }}>
<VisualWorkflowEditor
workflowName={editingWorkflow?.name || '编辑工作流'}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialNodes={editorData.nodes as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialEdges={editorData.edges as any}
onSave={async (nodes, edges) => {
if (!editingWorkflow) return;
try {
await workflowApi.update(editingWorkflow.id, { definition: JSON.stringify({ nodes, edges }) });
message.success('工作流已保存');
actionRef.current?.reload();
} catch { message.error('保存失败'); }
}}
onExecute={async (nodes, edges) => {
if (!editingWorkflow) return;
try {
const result = await workflowApi.execute(editingWorkflow.workflow_id, { workflow_data: { nodes, edges } });
message.success(`
执行已启动
:
$
{
result
.
data
?.
execution_id
}
`);
} catch { message.error('执行失败'); }
}}
/>
</div>
)}
</Drawer>
</div>
);
};
export default AdminWorkflowsPage;
web/src/pages/patient/TextConsult/index.tsx
View file @
04584395
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
'
react
'
;
import
React
,
{
useState
,
useEffect
,
useRef
}
from
'
react
'
;
import
{
useRouter
,
useParams
}
from
'
next/navigation
'
;
import
{
Card
,
Input
,
Button
,
List
,
Avatar
,
Typography
,
Space
,
Row
,
Col
,
Tag
,
Divider
,
message
}
from
'
antd
'
;
import
{
Input
,
Button
,
Avatar
,
Typography
,
Space
,
Tag
,
Divider
,
App
,
List
,
Empty
,
Tooltip
}
from
'
antd
'
;
import
{
SendOutlined
,
RobotOutlined
,
UserOutlined
,
PhoneOutlined
,
MedicineBoxOutlined
,
ClockCircleOutlined
,
ArrowLeftOutlined
,
CheckOutlined
,
CheckOutlined
,
MessageOutlined
,
PictureOutlined
,
SmileOutlined
,
FileTextOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useQuery
,
useMutation
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
{
consultApi
,
type
ConsultMessage
}
from
'
../../../api/consult
'
;
import
{
useUserStore
}
from
'
../../../store/userStore
'
;
import
dayjs
from
'
dayjs
'
;
const
{
Text
,
Title
}
=
Typography
;
const
{
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
const
statusMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
pending
:
{
text
:
'
等待接诊
'
,
color
:
'
orange
'
},
waiting
:
{
text
:
'
等待接诊
'
,
color
:
'
orange
'
},
in_progress
:
{
text
:
'
问诊中
'
,
color
:
'
green
'
},
completed
:
{
text
:
'
已完成
'
,
color
:
'
default
'
},
cancelled
:
{
text
:
'
已取消
'
,
color
:
'
red
'
},
const
statusMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
;
bg
:
string
}
>
=
{
pending
:
{
text
:
'
等待接诊
'
,
color
:
'
#fa8c16
'
,
bg
:
'
#fff7e6
'
},
waiting
:
{
text
:
'
等待接诊
'
,
color
:
'
#fa8c16
'
,
bg
:
'
#fff7e6
'
},
in_progress
:
{
text
:
'
问诊中
'
,
color
:
'
#52c41a
'
,
bg
:
'
#f6ffed
'
},
completed
:
{
text
:
'
已完成
'
,
color
:
'
#8c8c8c
'
,
bg
:
'
#fafafa
'
},
cancelled
:
{
text
:
'
已取消
'
,
color
:
'
#ff4d4f
'
,
bg
:
'
#fff2f0
'
},
};
const
PatientTextConsultPage
:
React
.
FC
=
()
=>
{
...
...
@@ -29,6 +30,7 @@ const PatientTextConsultPage: React.FC = () => {
const
id
=
params
?.
id
;
const
router
=
useRouter
();
const
{
user
}
=
useUserStore
();
const
{
message
}
=
App
.
useApp
();
const
queryClient
=
useQueryClient
();
const
[
inputValue
,
setInputValue
]
=
useState
(
''
);
const
messagesEndRef
=
useRef
<
HTMLDivElement
>
(
null
);
...
...
@@ -91,8 +93,16 @@ const PatientTextConsultPage: React.FC = () => {
if
(
isSystem
)
{
return
(
<
div
key=
{
msg
.
id
}
style=
{
{
textAlign
:
'
center
'
,
margin
:
'
12px 0
'
}
}
>
<
Tag
color=
"blue"
style=
{
{
fontSize
:
12
}
}
>
{
msg
.
content
}
</
Tag
>
<
div
key=
{
msg
.
id
}
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
center
'
,
margin
:
'
16px 0
'
}
}
>
<
div
style=
{
{
background
:
'
rgba(0,0,0,0.04)
'
,
borderRadius
:
12
,
padding
:
'
3px 14px
'
,
fontSize
:
11
,
color
:
'
#8c8c8c
'
,
}
}
>
{
msg
.
content
}
</
div
>
</
div
>
);
}
...
...
@@ -108,42 +118,58 @@ const PatientTextConsultPage: React.FC = () => {
>
{
!
isMe
&&
(
<
Avatar
icon=
{
isAI
?
<
RobotOutlined
/>
:
<
UserOutlined
/>
}
size=
{
36
}
icon=
{
isAI
?
<
RobotOutlined
/>
:
<
MedicineBoxOutlined
/>
}
src=
{
!
isAI
?
consult
?.
doctor_avatar
:
undefined
}
style=
{
{
backgroundColor
:
isAI
?
'
#722ed1
'
:
'
#1890ff
'
,
marginRight
:
8
,
flexShrink
:
0
}
}
style=
{
{
backgroundColor
:
isAI
?
'
#722ed1
'
:
'
#52c41a
'
,
marginRight
:
10
,
flexShrink
:
0
,
}
}
/>
)
}
<
div
style=
{
{
maxWidth
:
'
7
0%
'
}
}
>
{
!
isMe
&&
(
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
,
marginBottom
:
4
,
display
:
'
block
'
}
}
>
{
isAI
?
'
AI助手
'
:
consult
?.
doctor_name
||
'
医生
'
}
</
Text
>
)
}
<
div
style=
{
{
maxWidth
:
'
7
2%
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
alignItems
:
isMe
?
'
flex-end
'
:
'
flex-start
'
}
}
>
<
div
style=
{
{
fontSize
:
11
,
color
:
'
#aaa
'
,
marginBottom
:
5
}
}
>
{
isMe
?
'
我
'
:
isAI
?
'
AI助手
'
:
(
consult
?.
doctor_name
||
'
医生
'
)
}
{
'
·
'
}
{
dayjs
(
msg
.
created_at
).
format
(
'
HH:mm
'
)
}
</
div
>
<
div
style=
{
{
background
:
isMe
?
'
#1890ff
'
:
isAI
?
'
#f9f0ff
'
:
'
#f5f5f5
'
,
color
:
isMe
?
'
#fff
'
:
'
#000
'
,
padding
:
'
10px 14px
'
,
borderRadius
:
isMe
?
'
12px 12px 4px 12px
'
:
'
12px 12px 12px 4px
'
,
borderRadius
:
isMe
?
'
12px 4px 12px 12px
'
:
'
4px 12px 12px 12px
'
,
background
:
isMe
?
'
#1890ff
'
:
isAI
?
'
#f9f0ff
'
:
'
#fff
'
,
color
:
isMe
?
'
#fff
'
:
'
#1d2129
'
,
boxShadow
:
'
0 1px 3px rgba(0,0,0,0.08)
'
,
whiteSpace
:
'
pre-wrap
'
,
wordBreak
:
'
break-word
'
,
fontSize
:
14
,
lineHeight
:
1.6
,
}
}
>
{
msg
.
content
}
{
msg
.
content_type
===
'
image
'
?
(
<
img
src=
{
msg
.
media_url
||
msg
.
content
}
alt=
"图片"
style=
{
{
maxWidth
:
200
,
borderRadius
:
8
}
}
/>
)
:
msg
.
content_type
===
'
prescription
'
?
(
<
div
style=
{
{
padding
:
'
4px 0
'
}
}
>
<
div
style=
{
{
fontSize
:
12
,
fontWeight
:
600
,
color
:
isMe
?
'
#fff
'
:
'
#d48806
'
,
marginBottom
:
4
}
}
>
<
FileTextOutlined
style=
{
{
marginRight
:
4
}
}
/>
处方
</
div
>
<
div
style=
{
{
fontSize
:
13
}
}
>
{
msg
.
content
}
</
div
>
</
div
>
)
:
(
msg
.
content
)
}
</
div
>
<
div
style=
{
{
textAlign
:
isMe
?
'
right
'
:
'
left
'
,
marginTop
:
4
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
isMe
?
'
flex-end
'
:
'
flex-start
'
,
gap
:
6
}
}
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
11
}
}
>
{
dayjs
(
msg
.
created_at
).
format
(
'
HH:mm
'
)
}
</
Text
>
{
isMe
&&
msg
.
read_at
&&
(
<
span
style=
{
{
fontSize
:
10
,
color
:
'
#52c41a
'
}
}
>
<
div
style=
{
{
fontSize
:
10
,
color
:
'
#52c41a
'
,
marginTop
:
2
}
}
>
<
CheckOutlined
/><
CheckOutlined
style=
{
{
marginLeft
:
-
4
}
}
/>
已读
</
span
>
)
}
</
div
>
)
}
</
div
>
{
isMe
&&
(
<
Avatar
style=
{
{
marginLeft
:
8
,
flexShrink
:
0
,
backgroundColor
:
'
#52c41a
'
}
}
size=
{
36
}
style=
{
{
marginLeft
:
10
,
flexShrink
:
0
,
backgroundColor
:
'
#1890ff
'
}
}
src=
{
user
?.
avatar
}
icon=
{
<
UserOutlined
/>
}
/>
...
...
@@ -153,80 +179,218 @@ const PatientTextConsultPage: React.FC = () => {
};
return
(
<
div
style=
{
{
height
:
'
calc(100vh - 120px)
'
}
}
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
ArrowLeftOutlined
/>
}
onClick=
{
()
=>
router
.
push
(
'
/patient/consult
'
)
}
className=
"p-0! mb-1"
>
返回列表
</
Button
>
<
Row
gutter=
{
8
}
style=
{
{
height
:
'
calc(100% - 32px)
'
}
}
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
height
:
'
100%
'
}
}
styles=
{
{
body
:
{
padding
:
12
}
}
}
>
<
div
className=
"text-center mb-2"
>
<
Avatar
size=
{
48
}
src=
{
consult
?.
doctor_avatar
}
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#1890ff
'
}
}
/>
<
div
className=
"text-sm font-bold mt-1"
>
{
consult
?.
doctor_name
||
'
医生
'
}
</
div
>
<
Tag
color=
{
statusInfo
.
color
}
>
{
statusInfo
.
text
}
</
Tag
>
</
div
>
<
Divider
className=
"my-1!"
/>
<
div
className=
"text-xs space-y-2"
>
<
div
>
<
MedicineBoxOutlined
className=
"text-blue-500 mr-1"
/><
Text
type=
"secondary"
>
类型
</
Text
>
<
div
className=
"ml-4 mt-0.5"
><
Tag
color=
"green"
>
图文
</
Tag
></
div
>
<
div
style=
{
{
height
:
'
calc(100vh - 72px)
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
padding
:
'
4px 24px 16px
'
}
}
>
{
/* 顶部标题栏 */
}
<
div
style=
{
{
flexShrink
:
0
,
marginBottom
:
12
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
}
}
>
<
Button
type=
"text"
icon=
{
<
ArrowLeftOutlined
/>
}
onClick=
{
()
=>
router
.
push
(
'
/patient/consult
'
)
}
style=
{
{
color
:
'
#8c8c8c
'
,
padding
:
'
4px 8px
'
}
}
/>
<
div
style=
{
{
flex
:
1
}
}
>
<
div
style=
{
{
fontSize
:
20
,
fontWeight
:
700
,
color
:
'
#1d2129
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
8
}
}
>
<
MessageOutlined
style=
{
{
color
:
'
#1890ff
'
}
}
/>
图文问诊
</
div
>
<
div
>
<
ClockCircleOutlined
className=
"text-blue-500 mr-1"
/><
Text
type=
"secondary"
>
发起
</
Text
>
<
div
className=
"ml-4 mt-0.5"
>
{
consult
?.
created_at
?
dayjs
(
consult
.
created_at
).
format
(
'
MM-DD HH:mm
'
)
:
'
-
'
}
</
div
>
<
div
style=
{
{
fontSize
:
12
,
color
:
'
#8c8c8c
'
,
marginTop
:
2
}
}
>
{
consult
?.
doctor_name
?
`${consult.doctor_name} 医生`
:
'
在线问诊对话
'
}
</
div
>
</
div
>
<
div
style=
{
{
padding
:
'
4px 14px
'
,
borderRadius
:
8
,
background
:
statusInfo
.
bg
,
border
:
`1px solid ${statusInfo.color}30`
,
fontSize
:
13
,
fontWeight
:
500
,
color
:
statusInfo
.
color
,
}
}
>
{
statusInfo
.
text
}
</
div
>
</
div
>
</
div
>
{
/* 主体区域 */
}
<
div
style=
{
{
flex
:
1
,
display
:
'
flex
'
,
gap
:
12
,
overflow
:
'
hidden
'
,
minHeight
:
0
}
}
>
{
/* 左侧:医生信息面板 */
}
<
div
style=
{
{
width
:
240
,
flexShrink
:
0
,
borderRadius
:
12
,
border
:
'
1px solid #edf2fc
'
,
background
:
'
#fff
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
overflow
:
'
hidden
'
,
}
}
>
<
div
style=
{
{
padding
:
20
,
textAlign
:
'
center
'
}
}
>
<
Avatar
size=
{
56
}
src=
{
consult
?.
doctor_avatar
}
icon=
{
<
MedicineBoxOutlined
/>
}
style=
{
{
backgroundColor
:
'
#52c41a
'
}
}
/>
<
div
style=
{
{
fontSize
:
16
,
fontWeight
:
600
,
marginTop
:
10
,
color
:
'
#1d2129
'
}
}
>
{
consult
?.
doctor_name
||
'
医生
'
}
</
div
>
{
consult
?.
doctor_title
&&
(
<
Tag
color=
"blue"
style=
{
{
marginTop
:
6
,
fontSize
:
11
}
}
>
{
consult
.
doctor_title
}
</
Tag
>
)
}
</
div
>
<
Divider
style=
{
{
margin
:
0
}
}
/>
<
div
style=
{
{
padding
:
'
16px 20px
'
,
flex
:
1
}
}
>
<
div
style=
{
{
marginBottom
:
16
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
6
,
fontSize
:
12
,
color
:
'
#8c8c8c
'
,
marginBottom
:
6
}
}
>
<
MedicineBoxOutlined
style=
{
{
color
:
'
#1890ff
'
}
}
/>
问诊类型
</
div
>
<
Tag
color=
"green"
style=
{
{
marginLeft
:
18
}
}
>
图文问诊
</
Tag
>
</
div
>
<
div
style=
{
{
marginBottom
:
16
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
6
,
fontSize
:
12
,
color
:
'
#8c8c8c
'
,
marginBottom
:
6
}
}
>
<
ClockCircleOutlined
style=
{
{
color
:
'
#1890ff
'
}
}
/>
发起时间
</
div
>
<
div
style=
{
{
marginLeft
:
18
,
fontSize
:
13
,
color
:
'
#1d2129
'
}
}
>
{
consult
?.
created_at
?
dayjs
(
consult
.
created_at
).
format
(
'
YYYY-MM-DD HH:mm
'
)
:
'
-
'
}
</
div
>
</
div
>
{
consult
?.
started_at
&&
(
<
div
>
<
PhoneOutlined
className=
"text-green-500 mr-1"
/><
Text
type=
"secondary"
>
接诊
</
Text
>
<
div
className=
"ml-4 mt-0.5"
>
{
dayjs
(
consult
.
started_at
).
format
(
'
MM-DD HH:mm
'
)
}
</
div
>
<
div
style=
{
{
marginBottom
:
16
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
6
,
fontSize
:
12
,
color
:
'
#8c8c8c
'
,
marginBottom
:
6
}
}
>
<
PhoneOutlined
style=
{
{
color
:
'
#52c41a
'
}
}
/>
接诊时间
</
div
>
<
div
style=
{
{
marginLeft
:
18
,
fontSize
:
13
,
color
:
'
#1d2129
'
}
}
>
{
dayjs
(
consult
.
started_at
).
format
(
'
YYYY-MM-DD HH:mm
'
)
}
</
div
>
</
div
>
)
}
<
Divider
style=
{
{
margin
:
'
12px 0
'
}
}
/>
<
div
>
<
div
style=
{
{
fontSize
:
12
,
color
:
'
#8c8c8c
'
,
marginBottom
:
6
}
}
>
主诉
</
div
>
<
div
style=
{
{
fontSize
:
13
,
color
:
'
#1d2129
'
,
background
:
'
#f5f7fb
'
,
borderRadius
:
8
,
padding
:
'
10px 12px
'
,
lineHeight
:
1.6
,
}
}
>
{
consult
?.
chief_complaint
||
'
未填写
'
}
</
div
>
</
div
>
</
div
>
<
Divider
className=
"my-1!"
/>
<
div
className=
"text-[11px] text-gray-400 mb-1"
>
主诉
</
div
>
<
div
className=
"text-xs bg-gray-50 rounded p-2"
>
{
consult
?.
chief_complaint
||
'
未填写
'
}
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
18
}
>
<
Card
size=
"small"
title=
{
<
span
className=
"text-xs"
>
<
Avatar
size=
{
20
}
src=
{
consult
?.
doctor_avatar
}
icon=
{
<
UserOutlined
/>
}
className=
"mr-1"
/>
{
consult
?.
doctor_name
||
'
医生
'
}
<
Tag
color=
{
statusInfo
.
color
}
className=
"ml-1"
>
{
statusInfo
.
text
}
</
Tag
>
</
span
>
}
style=
{
{
height
:
'
100%
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
}
}
styles=
{
{
body
:
{
flex
:
1
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
padding
:
0
,
overflow
:
'
hidden
'
}
}
}
>
<
div
style=
{
{
flex
:
1
,
overflow
:
'
auto
'
,
padding
:
12
}
}
>
{
(
!
messagesData
?.
data
||
messagesData
.
data
.
length
===
0
)
?
(
<
div
className=
"text-center py-10 text-xs text-gray-400"
>
{
consult
?.
status
===
'
pending
'
||
consult
?.
status
===
'
waiting
'
?
'
等待医生接诊中..
'
:
'
暂无消息
'
}
</
div
>
{
/* 右侧:对话区域 */
}
<
div
style=
{
{
flex
:
1
,
minWidth
:
0
,
borderRadius
:
12
,
border
:
'
1px solid #edf2fc
'
,
background
:
'
#fff
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
overflow
:
'
hidden
'
,
}
}
>
{
/* 对话头部 */
}
<
div
style=
{
{
padding
:
'
10px 16px
'
,
borderBottom
:
'
1px solid #f0f0f0
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
10
,
flexShrink
:
0
,
}
}
>
<
Avatar
size=
{
32
}
src=
{
consult
?.
doctor_avatar
}
icon=
{
<
MedicineBoxOutlined
/>
}
style=
{
{
backgroundColor
:
'
#52c41a
'
}
}
/>
<
div
style=
{
{
flex
:
1
}
}
>
<
Text
strong
style=
{
{
fontSize
:
14
}
}
>
{
consult
?.
doctor_name
||
'
医生
'
}
</
Text
>
<
Tag
color=
{
statusInfo
.
color
===
'
#52c41a
'
?
'
success
'
:
statusInfo
.
color
===
'
#fa8c16
'
?
'
warning
'
:
'
default
'
}
style=
{
{
fontSize
:
11
,
marginLeft
:
8
}
}
>
{
statusInfo
.
text
}
</
Tag
>
</
div
>
</
div
>
{
/* 消息区域 */
}
<
div
style=
{
{
flex
:
1
,
overflow
:
'
auto
'
,
padding
:
16
,
background
:
'
#f5f7fb
'
}
}
>
{
(
!
messagesData
?.
data
||
messagesData
.
data
.
length
===
0
)
?
(
<
Empty
description=
{
<
span
style=
{
{
color
:
'
#8c8c8c
'
,
fontSize
:
13
}
}
>
{
consult
?.
status
===
'
pending
'
||
consult
?.
status
===
'
waiting
'
?
'
等待医生接诊中...
'
:
'
暂无消息
'
}
</
span
>
}
style=
{
{
marginTop
:
80
}
}
/>
)
:
(
<
List
dataSource=
{
messagesData
.
data
}
renderItem=
{
renderMessage
}
split=
{
false
}
/>
messagesData
.
data
.
map
(
renderMessage
)
)
}
<
div
ref=
{
messagesEndRef
}
/>
</
div
>
<
div
className=
"border-t border-gray-100 p-2 bg-gray-50"
>
{
/* 输入区域 */
}
<
div
style=
{
{
borderTop
:
'
1px solid #f0f0f0
'
,
background
:
'
#fff
'
,
flexShrink
:
0
}
}
>
{
canSendMessage
?
(
<
Space
.
Compact
style=
{
{
width
:
'
100%
'
}
}
>
<
TextArea
value=
{
inputValue
}
onChange=
{
(
e
)
=>
setInputValue
(
e
.
target
.
value
)
}
placeholder=
"输入消息..."
autoSize=
{
{
minRows
:
1
,
maxRows
:
3
}
}
size=
"small"
onPressEnter=
{
(
e
)
=>
{
if
(
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
}
style=
{
{
flex
:
1
}
}
/>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
SendOutlined
/>
}
onClick=
{
handleSend
}
loading=
{
sendMutation
.
isPending
}
>
发送
</
Button
>
</
Space
.
Compact
>
<>
{
/* 工具栏 */
}
<
div
style=
{
{
padding
:
'
6px 12px 0
'
,
display
:
'
flex
'
,
gap
:
2
,
borderBottom
:
'
1px solid #f5f5f5
'
}
}
>
<
Tooltip
title=
"发送图片"
>
<
Button
type=
"text"
size=
"small"
icon=
{
<
PictureOutlined
/>
}
style=
{
{
color
:
'
#1890ff
'
,
fontSize
:
13
}
}
/>
</
Tooltip
>
<
Tooltip
title=
"表情"
>
<
Button
type=
"text"
size=
"small"
icon=
{
<
SmileOutlined
/>
}
style=
{
{
color
:
'
#8c8c8c
'
,
fontSize
:
13
}
}
/>
</
Tooltip
>
</
div
>
{
/* 输入框 */
}
<
div
style=
{
{
padding
:
'
8px 12px 10px
'
,
display
:
'
flex
'
,
gap
:
8
,
alignItems
:
'
flex-end
'
}
}
>
<
TextArea
value=
{
inputValue
}
onChange=
{
(
e
)
=>
setInputValue
(
e
.
target
.
value
)
}
placeholder=
"输入消息,Enter 发送,Shift+Enter 换行..."
autoSize=
{
{
minRows
:
2
,
maxRows
:
5
}
}
onPressEnter=
{
(
e
)
=>
{
if
(
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
}
style=
{
{
flex
:
1
,
borderRadius
:
8
,
resize
:
'
none
'
}
}
/>
<
Button
type=
"primary"
icon=
{
<
SendOutlined
/>
}
onClick=
{
handleSend
}
loading=
{
sendMutation
.
isPending
}
style=
{
{
borderRadius
:
8
,
height
:
'
auto
'
,
paddingTop
:
8
,
paddingBottom
:
8
}
}
>
发送
</
Button
>
</
div
>
</>
)
:
(
<
div
className=
"text-center text-xs text-gray-400 py-1"
>
{
consult
?.
status
===
'
completed
'
?
'
问诊已结束
'
:
'
等待医生接诊后可发送消息
'
}
<
div
style=
{
{
padding
:
'
14px 16px
'
,
textAlign
:
'
center
'
,
color
:
'
#8c8c8c
'
,
fontSize
:
13
}
}
>
{
consult
?.
status
===
'
completed
'
?
'
本次
问诊已结束
'
:
'
等待医生接诊后可发送消息
'
}
</
div
>
)
}
</
div
>
</
Card
>
</
Col
>
</
Row
>
</
div
>
</
div
>
</
div
>
);
};
export
default
PatientTextConsultPage
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment