Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
aeec39a1
authored
Aug 04, 2025
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add multi-key management
parent
bb08de0b
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
353 additions
and
60 deletions
+353
-60
controller/channel.go
+258
-0
model/channel.go
+22
-11
model/channel_cache.go
+1
-1
router/api-router.go
+1
-0
web/src/components/table/channels/ChannelsColumnDefs.js
+47
-48
web/src/components/table/channels/ChannelsTable.jsx
+7
-0
web/src/components/table/channels/index.jsx
+7
-0
web/src/components/table/channels/modals/MultiKeyManageModal.jsx
+0
-0
web/src/hooks/channels/useChannelsData.js
+10
-0
No files found.
controller/channel.go
View file @
aeec39a1
...
...
@@ -1030,3 +1030,261 @@ func CopyChannel(c *gin.Context) {
// success
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
""
,
"data"
:
gin
.
H
{
"id"
:
clone
.
Id
}})
}
// MultiKeyManageRequest represents the request for multi-key management operations
type
MultiKeyManageRequest
struct
{
ChannelId
int
`json:"channel_id"`
Action
string
`json:"action"`
// "disable_key", "enable_key", "delete_disabled_keys", "get_key_status"
KeyIndex
*
int
`json:"key_index,omitempty"`
// for disable_key and enable_key actions
}
// MultiKeyStatusResponse represents the response for key status query
type
MultiKeyStatusResponse
struct
{
Keys
[]
KeyStatus
`json:"keys"`
}
type
KeyStatus
struct
{
Index
int
`json:"index"`
Status
int
`json:"status"`
// 1: enabled, 2: disabled
DisabledTime
int64
`json:"disabled_time,omitempty"`
Reason
string
`json:"reason,omitempty"`
KeyPreview
string
`json:"key_preview"`
// first 10 chars of key for identification
}
// ManageMultiKeys handles multi-key management operations
func
ManageMultiKeys
(
c
*
gin
.
Context
)
{
request
:=
MultiKeyManageRequest
{}
err
:=
c
.
ShouldBindJSON
(
&
request
)
if
err
!=
nil
{
common
.
ApiError
(
c
,
err
)
return
}
channel
,
err
:=
model
.
GetChannelById
(
request
.
ChannelId
,
true
)
if
err
!=
nil
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
false
,
"message"
:
"渠道不存在"
,
})
return
}
if
!
channel
.
ChannelInfo
.
IsMultiKey
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
false
,
"message"
:
"该渠道不是多密钥模式"
,
})
return
}
switch
request
.
Action
{
case
"get_key_status"
:
keys
:=
channel
.
GetKeys
()
var
keyStatusList
[]
KeyStatus
for
i
,
key
:=
range
keys
{
status
:=
1
// default enabled
var
disabledTime
int64
var
reason
string
if
channel
.
ChannelInfo
.
MultiKeyStatusList
!=
nil
{
if
s
,
exists
:=
channel
.
ChannelInfo
.
MultiKeyStatusList
[
i
];
exists
{
status
=
s
}
}
if
status
!=
1
{
if
channel
.
ChannelInfo
.
MultiKeyDisabledTime
!=
nil
{
disabledTime
=
channel
.
ChannelInfo
.
MultiKeyDisabledTime
[
i
]
}
if
channel
.
ChannelInfo
.
MultiKeyDisabledReason
!=
nil
{
reason
=
channel
.
ChannelInfo
.
MultiKeyDisabledReason
[
i
]
}
}
// Create key preview (first 10 chars)
keyPreview
:=
key
if
len
(
key
)
>
10
{
keyPreview
=
key
[
:
10
]
+
"..."
}
keyStatusList
=
append
(
keyStatusList
,
KeyStatus
{
Index
:
i
,
Status
:
status
,
DisabledTime
:
disabledTime
,
Reason
:
reason
,
KeyPreview
:
keyPreview
,
})
}
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
""
,
"data"
:
MultiKeyStatusResponse
{
Keys
:
keyStatusList
},
})
return
case
"disable_key"
:
if
request
.
KeyIndex
==
nil
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
false
,
"message"
:
"未指定要禁用的密钥索引"
,
})
return
}
keyIndex
:=
*
request
.
KeyIndex
if
keyIndex
<
0
||
keyIndex
>=
channel
.
ChannelInfo
.
MultiKeySize
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
false
,
"message"
:
"密钥索引超出范围"
,
})
return
}
if
channel
.
ChannelInfo
.
MultiKeyStatusList
==
nil
{
channel
.
ChannelInfo
.
MultiKeyStatusList
=
make
(
map
[
int
]
int
)
}
if
channel
.
ChannelInfo
.
MultiKeyDisabledTime
==
nil
{
channel
.
ChannelInfo
.
MultiKeyDisabledTime
=
make
(
map
[
int
]
int64
)
}
if
channel
.
ChannelInfo
.
MultiKeyDisabledReason
==
nil
{
channel
.
ChannelInfo
.
MultiKeyDisabledReason
=
make
(
map
[
int
]
string
)
}
channel
.
ChannelInfo
.
MultiKeyStatusList
[
keyIndex
]
=
2
// disabled
channel
.
ChannelInfo
.
MultiKeyDisabledTime
[
keyIndex
]
=
common
.
GetTimestamp
()
channel
.
ChannelInfo
.
MultiKeyDisabledReason
[
keyIndex
]
=
"手动禁用"
err
=
channel
.
Update
()
if
err
!=
nil
{
common
.
ApiError
(
c
,
err
)
return
}
model
.
InitChannelCache
()
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
"密钥已禁用"
,
})
return
case
"enable_key"
:
if
request
.
KeyIndex
==
nil
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
false
,
"message"
:
"未指定要启用的密钥索引"
,
})
return
}
keyIndex
:=
*
request
.
KeyIndex
if
keyIndex
<
0
||
keyIndex
>=
channel
.
ChannelInfo
.
MultiKeySize
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
false
,
"message"
:
"密钥索引超出范围"
,
})
return
}
// 从状态列表中删除该密钥的记录,使其回到默认启用状态
if
channel
.
ChannelInfo
.
MultiKeyStatusList
!=
nil
{
delete
(
channel
.
ChannelInfo
.
MultiKeyStatusList
,
keyIndex
)
}
if
channel
.
ChannelInfo
.
MultiKeyDisabledTime
!=
nil
{
delete
(
channel
.
ChannelInfo
.
MultiKeyDisabledTime
,
keyIndex
)
}
if
channel
.
ChannelInfo
.
MultiKeyDisabledReason
!=
nil
{
delete
(
channel
.
ChannelInfo
.
MultiKeyDisabledReason
,
keyIndex
)
}
err
=
channel
.
Update
()
if
err
!=
nil
{
common
.
ApiError
(
c
,
err
)
return
}
model
.
InitChannelCache
()
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
"密钥已启用"
,
})
return
case
"delete_disabled_keys"
:
keys
:=
channel
.
GetKeys
()
var
remainingKeys
[]
string
var
deletedCount
int
var
newStatusList
=
make
(
map
[
int
]
int
)
var
newDisabledTime
=
make
(
map
[
int
]
int64
)
var
newDisabledReason
=
make
(
map
[
int
]
string
)
newIndex
:=
0
for
i
,
key
:=
range
keys
{
status
:=
1
// default enabled
if
channel
.
ChannelInfo
.
MultiKeyStatusList
!=
nil
{
if
s
,
exists
:=
channel
.
ChannelInfo
.
MultiKeyStatusList
[
i
];
exists
{
status
=
s
}
}
// 只删除自动禁用(status == 3)的密钥,保留启用(status == 1)和手动禁用(status == 2)的密钥
if
status
==
3
{
deletedCount
++
}
else
{
remainingKeys
=
append
(
remainingKeys
,
key
)
// 保留非自动禁用密钥的状态信息,重新索引
if
status
!=
1
{
newStatusList
[
newIndex
]
=
status
if
channel
.
ChannelInfo
.
MultiKeyDisabledTime
!=
nil
{
if
t
,
exists
:=
channel
.
ChannelInfo
.
MultiKeyDisabledTime
[
i
];
exists
{
newDisabledTime
[
newIndex
]
=
t
}
}
if
channel
.
ChannelInfo
.
MultiKeyDisabledReason
!=
nil
{
if
r
,
exists
:=
channel
.
ChannelInfo
.
MultiKeyDisabledReason
[
i
];
exists
{
newDisabledReason
[
newIndex
]
=
r
}
}
}
newIndex
++
}
}
if
deletedCount
==
0
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
false
,
"message"
:
"没有需要删除的自动禁用密钥"
,
})
return
}
// Update channel with remaining keys
channel
.
Key
=
strings
.
Join
(
remainingKeys
,
"
\n
"
)
channel
.
ChannelInfo
.
MultiKeySize
=
len
(
remainingKeys
)
channel
.
ChannelInfo
.
MultiKeyStatusList
=
newStatusList
channel
.
ChannelInfo
.
MultiKeyDisabledTime
=
newDisabledTime
channel
.
ChannelInfo
.
MultiKeyDisabledReason
=
newDisabledReason
err
=
channel
.
Update
()
if
err
!=
nil
{
common
.
ApiError
(
c
,
err
)
return
}
model
.
InitChannelCache
()
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
fmt
.
Sprintf
(
"已删除 %d 个自动禁用的密钥"
,
deletedCount
),
"data"
:
deletedCount
,
})
return
default
:
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
false
,
"message"
:
"不支持的操作"
,
})
return
}
}
model/channel.go
View file @
aeec39a1
...
...
@@ -41,6 +41,7 @@ type Channel struct {
Priority
*
int64
`json:"priority" gorm:"bigint;default:0"`
AutoBan
*
int
`json:"auto_ban" gorm:"default:1"`
OtherInfo
string
`json:"other_info"`
Settings
string
`json:"settings"`
Tag
*
string
`json:"tag" gorm:"index"`
Setting
*
string
`json:"setting" gorm:"type:text"`
// 渠道额外设置
ParamOverride
*
string
`json:"param_override" gorm:"type:text"`
...
...
@@ -52,11 +53,13 @@ type Channel struct {
}
type
ChannelInfo
struct
{
IsMultiKey
bool
`json:"is_multi_key"`
// 是否多Key模式
MultiKeySize
int
`json:"multi_key_size"`
// 多Key模式下的Key数量
MultiKeyStatusList
map
[
int
]
int
`json:"multi_key_status_list"`
// key状态列表,key index -> status
MultiKeyPollingIndex
int
`json:"multi_key_polling_index"`
// 多Key模式下轮询的key索引
MultiKeyMode
constant
.
MultiKeyMode
`json:"multi_key_mode"`
IsMultiKey
bool
`json:"is_multi_key"`
// 是否多Key模式
MultiKeySize
int
`json:"multi_key_size"`
// 多Key模式下的Key数量
MultiKeyStatusList
map
[
int
]
int
`json:"multi_key_status_list"`
// key状态列表,key index -> status
MultiKeyDisabledReason
map
[
int
]
string
`json:"multi_key_disabled_reason"`
// key禁用原因列表,key index -> reason
MultiKeyDisabledTime
map
[
int
]
int64
`json:"multi_key_disabled_time"`
// key禁用时间列表,key index -> time
MultiKeyPollingIndex
int
`json:"multi_key_polling_index"`
// 多Key模式下轮询的key索引
MultiKeyMode
constant
.
MultiKeyMode
`json:"multi_key_mode"`
}
// Value implements driver.Valuer interface
...
...
@@ -70,7 +73,7 @@ func (c *ChannelInfo) Scan(value interface{}) error {
return
common
.
Unmarshal
(
bytesValue
,
c
)
}
func
(
channel
*
Channel
)
g
etKeys
()
[]
string
{
func
(
channel
*
Channel
)
G
etKeys
()
[]
string
{
if
channel
.
Key
==
""
{
return
[]
string
{}
}
...
...
@@ -101,7 +104,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
}
// Obtain all keys (split by \n)
keys
:=
channel
.
g
etKeys
()
keys
:=
channel
.
G
etKeys
()
if
len
(
keys
)
==
0
{
// No keys available, return error, should disable the channel
return
""
,
0
,
types
.
NewError
(
errors
.
New
(
"no keys available"
),
types
.
ErrorCodeChannelNoAvailableKey
)
...
...
@@ -528,8 +531,8 @@ func CleanupChannelPollingLocks() {
})
}
func
handlerMultiKeyUpdate
(
channel
*
Channel
,
usingKey
string
,
status
int
)
{
keys
:=
channel
.
g
etKeys
()
func
handlerMultiKeyUpdate
(
channel
*
Channel
,
usingKey
string
,
status
int
,
reason
string
)
{
keys
:=
channel
.
G
etKeys
()
if
len
(
keys
)
==
0
{
channel
.
Status
=
status
}
else
{
...
...
@@ -547,6 +550,14 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
delete
(
channel
.
ChannelInfo
.
MultiKeyStatusList
,
keyIndex
)
}
else
{
channel
.
ChannelInfo
.
MultiKeyStatusList
[
keyIndex
]
=
status
if
channel
.
ChannelInfo
.
MultiKeyDisabledReason
==
nil
{
channel
.
ChannelInfo
.
MultiKeyDisabledReason
=
make
(
map
[
int
]
string
)
}
if
channel
.
ChannelInfo
.
MultiKeyDisabledTime
==
nil
{
channel
.
ChannelInfo
.
MultiKeyDisabledTime
=
make
(
map
[
int
]
int64
)
}
channel
.
ChannelInfo
.
MultiKeyDisabledReason
[
keyIndex
]
=
reason
channel
.
ChannelInfo
.
MultiKeyDisabledTime
[
keyIndex
]
=
common
.
GetTimestamp
()
}
if
len
(
channel
.
ChannelInfo
.
MultiKeyStatusList
)
>=
channel
.
ChannelInfo
.
MultiKeySize
{
channel
.
Status
=
common
.
ChannelStatusAutoDisabled
...
...
@@ -569,7 +580,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
}
if
channelCache
.
ChannelInfo
.
IsMultiKey
{
// 如果是多Key模式,更新缓存中的状态
handlerMultiKeyUpdate
(
channelCache
,
usingKey
,
status
)
handlerMultiKeyUpdate
(
channelCache
,
usingKey
,
status
,
reason
)
//CacheUpdateChannel(channelCache)
//return true
}
else
{
...
...
@@ -600,7 +611,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
if
channel
.
ChannelInfo
.
IsMultiKey
{
beforeStatus
:=
channel
.
Status
handlerMultiKeyUpdate
(
channel
,
usingKey
,
status
)
handlerMultiKeyUpdate
(
channel
,
usingKey
,
status
,
reason
)
if
beforeStatus
!=
channel
.
Status
{
shouldUpdateAbilities
=
true
}
...
...
model/channel_cache.go
View file @
aeec39a1
...
...
@@ -70,7 +70,7 @@ func InitChannelCache() {
//channelsIDM = newChannelId2channel
for
i
,
channel
:=
range
newChannelId2channel
{
if
channel
.
ChannelInfo
.
IsMultiKey
{
channel
.
Keys
=
channel
.
g
etKeys
()
channel
.
Keys
=
channel
.
G
etKeys
()
if
channel
.
ChannelInfo
.
MultiKeyMode
==
constant
.
MultiKeyModePolling
{
if
oldChannel
,
ok
:=
channelsIDM
[
i
];
ok
{
// 存在旧的渠道,如果是多key且轮询,保留轮询索引信息
...
...
router/api-router.go
View file @
aeec39a1
...
...
@@ -120,6 +120,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute
.
POST
(
"/batch/tag"
,
controller
.
BatchSetChannelTag
)
channelRoute
.
GET
(
"/tag/models"
,
controller
.
GetTagModels
)
channelRoute
.
POST
(
"/copy/:id"
,
controller
.
CopyChannel
)
channelRoute
.
POST
(
"/multi_key/manage"
,
controller
.
ManageMultiKeys
)
}
tokenRoute
:=
apiRouter
.
Group
(
"/token"
)
tokenRoute
.
Use
(
middleware
.
UserAuth
())
...
...
web/src/components/table/channels/ChannelsColumnDefs.js
View file @
aeec39a1
...
...
@@ -210,7 +210,9 @@ export const getChannelsColumns = ({
copySelectedChannel
,
refresh
,
activePage
,
channels
channels
,
setShowMultiKeyManageModal
,
setCurrentMultiKeyChannel
})
=>
{
return
[
{
...
...
@@ -503,36 +505,50 @@ export const getChannelsColumns = ({
/
>
<
/SplitButtonGroup
>
{
record
.
status
===
1
?
(
<
Button
type
=
'danger'
size
=
"small"
onClick
=
{()
=>
manageChannel
(
record
.
id
,
'disable'
,
record
)}
>
{
t
(
'禁用'
)}
<
/Button
>
)
:
(
<
Button
size
=
"small"
onClick
=
{()
=>
manageChannel
(
record
.
id
,
'enable'
,
record
)}
>
{
t
(
'启用'
)}
<
/Button
>
)
}
{
record
.
channel_info
?.
is_multi_key
?
(
<
SplitButtonGroup
aria
-
label
=
{
t
(
'多密钥渠道操作项目组'
)}
>
{
record
.
status
===
1
?
(
<
Button
type
=
'danger'
size
=
"small"
onClick
=
{()
=>
manageChannel
(
record
.
id
,
'disable'
,
record
)}
>
{
t
(
'禁用'
)}
<
/Button
>
)
:
(
<
Button
size
=
"small"
onClick
=
{()
=>
manageChannel
(
record
.
id
,
'enable'
,
record
)}
>
{
t
(
'启用'
)}
<
/Button
>
)
}
<
Button
type
=
'tertiary'
size
=
"small"
onClick
=
{()
=>
{
setEditingChannel
(
record
);
setShowEdit
(
true
);
}}
>
{
t
(
'编辑'
)}
<
/Button
>
<
Dropdown
trigger
=
'click'
position
=
'bottomRight'
menu
=
{[
{
node
:
'item'
,
name
:
t
(
'启用全部密钥'
),
onClick
:
()
=>
manageChannel
(
record
.
id
,
'enable_all'
,
record
),
name
:
t
(
'多key管理'
),
onClick
:
()
=>
{
setCurrentMultiKeyChannel
(
record
);
setShowMultiKeyManageModal
(
true
);
},
}
]}
>
...
...
@@ -544,35 +560,18 @@ export const getChannelsColumns = ({
<
/Dropdown
>
<
/SplitButtonGroup
>
)
:
(
record
.
status
===
1
?
(
<
Button
type
=
'danger'
size
=
"small"
onClick
=
{()
=>
manageChannel
(
record
.
id
,
'disable'
,
record
)}
>
{
t
(
'禁用'
)}
<
/Button
>
)
:
(
<
Button
size
=
"small"
onClick
=
{()
=>
manageChannel
(
record
.
id
,
'enable'
,
record
)}
>
{
t
(
'启用'
)}
<
/Button
>
)
<
Button
type
=
'tertiary'
size
=
"small"
onClick
=
{()
=>
{
setEditingChannel
(
record
);
setShowEdit
(
true
);
}}
>
{
t
(
'编辑'
)}
<
/Button
>
)}
<
Button
type
=
'tertiary'
size
=
"small"
onClick
=
{()
=>
{
setEditingChannel
(
record
);
setShowEdit
(
true
);
}}
>
{
t
(
'编辑'
)}
<
/Button
>
<
Dropdown
trigger
=
'click'
position
=
'bottomRight'
...
...
web/src/components/table/channels/ChannelsTable.jsx
View file @
aeec39a1
...
...
@@ -57,6 +57,9 @@ const ChannelsTable = (channelsData) => {
setEditingTag
,
copySelectedChannel
,
refresh
,
// Multi-key management
setShowMultiKeyManageModal
,
setCurrentMultiKeyChannel
,
}
=
channelsData
;
// Get all columns
...
...
@@ -79,6 +82,8 @@ const ChannelsTable = (channelsData) => {
refresh
,
activePage
,
channels
,
setShowMultiKeyManageModal
,
setCurrentMultiKeyChannel
,
});
},
[
t
,
...
...
@@ -98,6 +103,8 @@ const ChannelsTable = (channelsData) => {
refresh
,
activePage
,
channels
,
setShowMultiKeyManageModal
,
setCurrentMultiKeyChannel
,
]);
// Filter columns based on visibility settings
...
...
web/src/components/table/channels/index.jsx
View file @
aeec39a1
...
...
@@ -30,6 +30,7 @@ import ModelTestModal from './modals/ModelTestModal.jsx';
import
ColumnSelectorModal
from
'./modals/ColumnSelectorModal.jsx'
;
import
EditChannelModal
from
'./modals/EditChannelModal.jsx'
;
import
EditTagModal
from
'./modals/EditTagModal.jsx'
;
import
MultiKeyManageModal
from
'./modals/MultiKeyManageModal.jsx'
;
import
{
createCardProPagination
}
from
'../../../helpers/utils'
;
const
ChannelsPage
=
()
=>
{
...
...
@@ -54,6 +55,12 @@ const ChannelsPage = () => {
/>
<
BatchTagModal
{
...
channelsData
}
/>
<
ModelTestModal
{
...
channelsData
}
/>
<
MultiKeyManageModal
visible=
{
channelsData
.
showMultiKeyManageModal
}
onCancel=
{
()
=>
channelsData
.
setShowMultiKeyManageModal
(
false
)
}
channel=
{
channelsData
.
currentMultiKeyChannel
}
onRefresh=
{
channelsData
.
refresh
}
/>
{
/* Main Content */
}
<
CardPro
...
...
web/src/components/table/channels/modals/MultiKeyManageModal.jsx
0 → 100644
View file @
aeec39a1
This diff is collapsed.
Click to expand it.
web/src/hooks/channels/useChannelsData.js
View file @
aeec39a1
...
...
@@ -83,6 +83,10 @@ export const useChannelsData = () => {
const
[
isProcessingQueue
,
setIsProcessingQueue
]
=
useState
(
false
);
const
[
modelTablePage
,
setModelTablePage
]
=
useState
(
1
);
// Multi-key management states
const
[
showMultiKeyManageModal
,
setShowMultiKeyManageModal
]
=
useState
(
false
);
const
[
currentMultiKeyChannel
,
setCurrentMultiKeyChannel
]
=
useState
(
null
);
// Refs
const
requestCounter
=
useRef
(
0
);
const
allSelectingRef
=
useRef
(
false
);
...
...
@@ -885,6 +889,12 @@ export const useChannelsData = () => {
setModelTablePage
,
allSelectingRef
,
// Multi-key management states
showMultiKeyManageModal
,
setShowMultiKeyManageModal
,
currentMultiKeyChannel
,
setCurrentMultiKeyChannel
,
// Form
formApi
,
setFormApi
,
...
...
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