完成计划任务功能

This commit is contained in:
dushixiang 2021-03-05 15:14:37 +08:00
parent 2d06cd373f
commit f81aedcac0
11 changed files with 375 additions and 112 deletions

View File

@ -24,7 +24,7 @@ import (
"time" "time"
) )
const Version = "v0.2.7" const Version = "v0.3.0"
func main() { func main() {
log.Fatal(Run()) log.Fatal(Run())
@ -182,6 +182,7 @@ func Run() error {
}) })
global.Store = global.NewStore() global.Store = global.NewStore()
global.Cron = cron.New(cron.WithSeconds()) //精确到秒 global.Cron = cron.New(cron.WithSeconds()) //精确到秒
global.Cron.Start()
jobs, err := model.FindJobByFunc(model.FuncCheckAssetStatusJob) jobs, err := model.FindJobByFunc(model.FuncCheckAssetStatusJob)
if err != nil { if err != nil {
@ -193,6 +194,7 @@ func Run() error {
Name: "资产状态检测", Name: "资产状态检测",
Func: model.FuncCheckAssetStatusJob, Func: model.FuncCheckAssetStatusJob,
Cron: "0 0 0/1 * * ?", Cron: "0 0 0/1 * * ?",
Mode: model.JobModeAll,
Status: model.JobStatusRunning, Status: model.JobStatusRunning,
Created: utils.NowJsonTime(), Created: utils.NowJsonTime(),
Updated: utils.NowJsonTime(), Updated: utils.NowJsonTime(),
@ -200,6 +202,7 @@ func Run() error {
if err := model.CreateNewJob(&job); err != nil { if err := model.CreateNewJob(&job); err != nil {
return err return err
} }
logrus.Debugf("创建计划任务「%v」cron「%v」", job.Name, job.Cron)
} else { } else {
for i := range jobs { for i := range jobs {
if jobs[i].Status == model.JobStatusRunning { if jobs[i].Status == model.JobStatusRunning {
@ -207,6 +210,7 @@ func Run() error {
if err != nil { if err != nil {
return err return err
} }
logrus.Debugf("启动计划任务「%v」cron「%v」", jobs[i].Name, jobs[i].Cron)
} }
} }
} }

View File

@ -3,6 +3,7 @@ package api
import ( import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"next-terminal/pkg/model" "next-terminal/pkg/model"
"next-terminal/pkg/utils"
"strconv" "strconv"
"strings" "strings"
) )
@ -13,6 +14,9 @@ func JobCreateEndpoint(c echo.Context) error {
return err return err
} }
item.ID = utils.UUID()
item.Created = utils.NowJsonTime()
if err := model.CreateNewJob(&item); err != nil { if err := model.CreateNewJob(&item); err != nil {
return err return err
} }

View File

@ -27,7 +27,7 @@ func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc {
func Auth(next echo.HandlerFunc) echo.HandlerFunc { func Auth(next echo.HandlerFunc) echo.HandlerFunc {
urls := []string{"download", "recording", "login", "static", "favicon", "logo", "asciinema"} urls := []string{"/download", "/recording", "/login", "/static", "/favicon.ico", "/logo.svg", "/asciinema"}
return func(c echo.Context) error { return func(c echo.Context) error {
// 路由拦截 - 登录身份、资源权限判断等 // 路由拦截 - 登录身份、资源权限判断等
@ -35,7 +35,7 @@ func Auth(next echo.HandlerFunc) echo.HandlerFunc {
if c.Request().RequestURI == "/" || strings.HasPrefix(c.Request().RequestURI, "/#") { if c.Request().RequestURI == "/" || strings.HasPrefix(c.Request().RequestURI, "/#") {
return next(c) return next(c)
} }
if strings.Contains(c.Request().RequestURI, urls[i]) { if strings.HasPrefix(c.Request().RequestURI, urls[i]) {
return next(c) return next(c)
} }
} }

View File

@ -46,10 +46,8 @@ func SessionPagingEndpoint(c echo.Context) error {
} }
if utils.FileExists(recording) { if utils.FileExists(recording) {
logrus.Debugf("检测到录屏文件[%v]存在", recording)
items[i].Recording = "1" items[i].Recording = "1"
} else { } else {
logrus.Warnf("检测到录屏文件[%v]不存在", recording)
items[i].Recording = "0" items[i].Recording = "0"
} }
} else { } else {

View File

@ -2,7 +2,6 @@ package handle
import ( import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"next-terminal/pkg/global"
"next-terminal/pkg/guacd" "next-terminal/pkg/guacd"
"next-terminal/pkg/model" "next-terminal/pkg/model"
"next-terminal/pkg/utils" "next-terminal/pkg/utils"
@ -14,7 +13,9 @@ import (
func RunTicker() { func RunTicker() {
// 每隔一小时删除一次未使用的会话信息 // 每隔一小时删除一次未使用的会话信息
_, _ = global.Cron.AddFunc("0 0 0/1 * * ?", func() { unUsedSessionTicker := time.NewTicker(time.Minute * 60)
go func() {
for range unUsedSessionTicker.C {
sessions, _ := model.FindSessionByStatusIn([]string{model.NoConnect, model.Connecting}) sessions, _ := model.FindSessionByStatusIn([]string{model.NoConnect, model.Connecting})
if sessions != nil && len(sessions) > 0 { if sessions != nil && len(sessions) > 0 {
now := time.Now() now := time.Now()
@ -26,9 +27,13 @@ func RunTicker() {
} }
} }
} }
}) }
}()
// 每日凌晨删除超过时长限制的会话 // 每日凌晨删除超过时长限制的会话
_, _ = global.Cron.AddFunc("0 0 0 * * ?", func() { timeoutSessionTicker := time.NewTicker(time.Hour * 24)
go func() {
for range timeoutSessionTicker.C {
property, err := model.FindPropertyByName("session-saved-limit") property, err := model.FindPropertyByName("session-saved-limit")
if err != nil { if err != nil {
return return
@ -55,9 +60,8 @@ func RunTicker() {
logrus.Errorf("删除离线会话失败 %v", err) logrus.Errorf("删除离线会话失败 %v", err)
} }
} }
}) }
}()
global.Cron.Start()
} }
func RunDataFix() { func RunDataFix() {

View File

@ -48,6 +48,21 @@ func FindAllAsset() (o []Asset, err error) {
return return
} }
func FindAssetByIds(assetIds []string) (o []Asset, err error) {
err = global.DB.Where("id in ?", assetIds).Find(&o).Error
return
}
func FindAssetByProtocol(protocol string) (o []Asset, err error) {
err = global.DB.Where("protocol = ?", protocol).Find(&o).Error
return
}
func FindAssetByProtocolAndIds(protocol string, assetIds []string) (o []Asset, err error) {
err = global.DB.Where("protocol = ? and id in ?", protocol, assetIds).Find(&o).Error
return
}
func FindAssetByConditions(protocol string, account User) (o []Asset, err error) { func FindAssetByConditions(protocol string, account User) (o []Asset, err error) {
db := global.DB.Table("assets").Select("assets.id,assets.name,assets.ip,assets.port,assets.protocol,assets.active,assets.owner,assets.created, users.nickname as owner_name,COUNT(resource_sharers.user_id) as sharer_count").Joins("left join users on assets.owner = users.id").Joins("left join resource_sharers on assets.id = resource_sharers.resource_id").Group("assets.id") db := global.DB.Table("assets").Select("assets.id,assets.name,assets.ip,assets.port,assets.protocol,assets.active,assets.owner,assets.created, users.nickname as owner_name,COUNT(resource_sharers.user_id) as sharer_count").Joins("left join users on assets.owner = users.id").Joins("left join resource_sharers on assets.id = resource_sharers.resource_id").Group("assets.id")

View File

@ -1,12 +1,15 @@
package model package model
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"next-terminal/pkg/global" "next-terminal/pkg/global"
"next-terminal/pkg/term"
"next-terminal/pkg/utils" "next-terminal/pkg/utils"
"strings"
"time" "time"
) )
@ -15,6 +18,10 @@ const (
JobStatusNotRunning = "not-running" JobStatusNotRunning = "not-running"
FuncCheckAssetStatusJob = "check-asset-status-job" FuncCheckAssetStatusJob = "check-asset-status-job"
FuncShellJob = "shell-job"
JobModeAll = "all"
JobModeCustom = "custom"
) )
type Job struct { type Job struct {
@ -23,6 +30,8 @@ type Job struct {
Name string `json:"name"` Name string `json:"name"`
Func string `json:"func"` Func string `json:"func"`
Cron string `json:"cron"` Cron string `json:"cron"`
Mode string `json:"mode"`
ResourceIds string `json:"resourceIds"`
Status string `json:"status"` Status string `json:"status"`
Metadata string `json:"metadata"` Metadata string `json:"metadata"`
Created utils.JsonTime `json:"created"` Created utils.JsonTime `json:"created"`
@ -69,7 +78,7 @@ func FindJobByFunc(function string) (o []Job, err error) {
func CreateNewJob(o *Job) (err error) { func CreateNewJob(o *Job) (err error) {
if o.Status == JobStatusRunning { if o.Status == JobStatusRunning {
j, err := getJob(o.ID, o.Func) j, err := getJob(o)
if err != nil { if err != nil {
return err return err
} }
@ -103,7 +112,7 @@ func ChangeJobStatusById(id, status string) (err error) {
return err return err
} }
if status == JobStatusRunning { if status == JobStatusRunning {
j, err := getJob(job.ID, job.Func) j, err := getJob(&job)
if err != nil { if err != nil {
return err return err
} }
@ -111,10 +120,12 @@ func ChangeJobStatusById(id, status string) (err error) {
if err != nil { if err != nil {
return err return err
} }
job.CronJobId = int(entryID) logrus.Debugf("开启计划任务「%v」,运行中计划任务数量「%v」", job.Name, len(global.Cron.Entries()))
return global.DB.Updates(Job{ID: id, Status: JobStatusRunning}).Error
return global.DB.Updates(Job{ID: id, Status: JobStatusRunning, CronJobId: int(entryID)}).Error
} else { } else {
global.Cron.Remove(cron.EntryID(job.CronJobId)) global.Cron.Remove(cron.EntryID(job.CronJobId))
logrus.Debugf("关闭计划任务「%v」,运行中计划任务数量「%v」", job.Name, len(global.Cron.Entries()))
return global.DB.Updates(Job{ID: id, Status: JobStatusNotRunning}).Error return global.DB.Updates(Job{ID: id, Status: JobStatusNotRunning}).Error
} }
} }
@ -124,7 +135,7 @@ func ExecJobById(id string) (err error) {
if err != nil { if err != nil {
return err return err
} }
j, err := getJob(id, job.Func) j, err := getJob(&job)
if err != nil { if err != nil {
return err return err
} }
@ -150,10 +161,12 @@ func DeleteJobById(id string) error {
return global.DB.Where("id = ?", id).Delete(Job{}).Error return global.DB.Where("id = ?", id).Delete(Job{}).Error
} }
func getJob(id, function string) (job cron.Job, err error) { func getJob(j *Job) (job cron.Job, err error) {
switch function { switch j.Func {
case FuncCheckAssetStatusJob: case FuncCheckAssetStatusJob:
job = CheckAssetStatusJob{ID: id} job = CheckAssetStatusJob{ID: j.ID, Mode: j.Mode, ResourceIds: j.ResourceIds, Metadata: j.Metadata}
case FuncShellJob:
job = ShellJob{ID: j.ID, Mode: j.Mode, ResourceIds: j.ResourceIds, Metadata: j.Metadata}
default: default:
return nil, errors.New("未识别的任务") return nil, errors.New("未识别的任务")
} }
@ -162,11 +175,26 @@ func getJob(id, function string) (job cron.Job, err error) {
type CheckAssetStatusJob struct { type CheckAssetStatusJob struct {
ID string ID string
Mode string
ResourceIds string
Metadata string
} }
func (r CheckAssetStatusJob) Run() { func (r CheckAssetStatusJob) Run() {
assets, _ := FindAllAsset() if r.ID == "" {
if assets != nil && len(assets) > 0 { return
}
var assets []Asset
if r.Mode == JobModeAll {
assets, _ = FindAllAsset()
} else {
assets, _ = FindAssetByIds(strings.Split(r.ResourceIds, ","))
}
if assets == nil || len(assets) == 0 {
return
}
msgChan := make(chan string) msgChan := make(chan string)
for i := range assets { for i := range assets {
@ -183,7 +211,6 @@ func (r CheckAssetStatusJob) Run() {
}() }()
} }
if r.ID != "" {
var message = "" var message = ""
for i := 0; i < len(assets); i++ { for i := 0; i < len(assets); i++ {
message += <-msgChan + "\n" message += <-msgChan + "\n"
@ -198,7 +225,125 @@ func (r CheckAssetStatusJob) Run() {
} }
_ = CreateNewJobLog(&jobLog) _ = CreateNewJobLog(&jobLog)
}
type ShellJob struct {
ID string
Mode string
ResourceIds string
Metadata string
}
type MetadataShell struct {
Shell string
}
func (r ShellJob) Run() {
if r.ID == "" {
return
}
var assets []Asset
if r.Mode == JobModeAll {
assets, _ = FindAssetByProtocol("ssh")
} else {
assets, _ = FindAssetByProtocolAndIds("ssh", strings.Split(r.ResourceIds, ","))
}
if assets == nil || len(assets) == 0 {
return
}
var metadataShell MetadataShell
err := json.Unmarshal([]byte(r.Metadata), &metadataShell)
if err != nil {
logrus.Errorf("JSON数据解析失败 %v", err)
return
}
msgChan := make(chan string)
for i := range assets {
asset, err := FindAssetById(assets[i].ID)
if err != nil {
msgChan <- fmt.Sprintf("资产「%v」Shell执行失败查询数据异常「%v」", assets[i].Name, err.Error())
return
}
var (
username = asset.Username
password = asset.Password
privateKey = asset.PrivateKey
passphrase = asset.Passphrase
ip = asset.IP
port = asset.Port
)
if asset.AccountType == "credential" {
credential, err := FindCredentialById(asset.CredentialId)
if err != nil {
msgChan <- fmt.Sprintf("资产「%v」Shell执行失败查询授权凭证数据异常「%v」", assets[i].Name, err.Error())
return
}
if credential.Type == Custom {
username = credential.Username
password = credential.Password
} else {
username = credential.Username
privateKey = credential.PrivateKey
passphrase = credential.Passphrase
} }
} }
go func() {
t1 := time.Now()
result, err := ExecCommandBySSH(metadataShell.Shell, ip, port, username, password, privateKey, passphrase)
elapsed := time.Since(t1)
var msg string
if err != nil {
msg = fmt.Sprintf("资产「%v」Shell执行失败返回值「%v」耗时「%v」", asset.Name, err.Error(), elapsed)
logrus.Infof(msg)
} else {
msg = fmt.Sprintf("资产「%v」Shell执行成功返回值「%v」耗时「%v」", asset.Name, result, elapsed)
logrus.Infof(msg)
}
msgChan <- msg
}()
}
var message = ""
for i := 0; i < len(assets); i++ {
message += <-msgChan + "\n"
}
_ = UpdateJonUpdatedById(r.ID)
jobLog := JobLog{
ID: utils.UUID(),
JobId: r.ID,
Timestamp: utils.NowJsonTime(),
Message: message,
}
_ = CreateNewJobLog(&jobLog)
}
func ExecCommandBySSH(cmd, ip string, port int, username, password, privateKey, passphrase string) (result string, err error) {
sshClient, err := term.NewSshClient(ip, port, username, password, privateKey, passphrase)
if err != nil {
return "", err
}
session, err := sshClient.NewSession()
if err != nil {
return "", err
}
defer session.Close()
//执行远程命令
combo, err := session.CombinedOutput(cmd)
if err != nil {
return "", err
}
return string(combo), nil
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "next-terminal", "name": "next-terminal",
"version": "0.2.7", "version": "0.3.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@ant-design/icons": "^4.3.0", "@ant-design/icons": "^4.3.0",

View File

@ -490,7 +490,7 @@ class Asset extends Component {
if (tags[i] === '-') { if (tags[i] === '-') {
continue; continue;
} }
tagDocuments.push(<Tag>{tagArr[i]}</Tag>) tagDocuments.push(<Tag key={tagArr[i]}>{tagArr[i]}</Tag>)
} }
return tagDocuments; return tagDocuments;
} }

View File

@ -12,10 +12,10 @@ import {
PageHeader, PageHeader,
Row, Row,
Space, Space,
Spin,
Switch, Switch,
Table, Table,
Tag, Tag,
Timeline,
Tooltip, Tooltip,
Typography Typography
} from "antd"; } from "antd";
@ -68,6 +68,7 @@ class Job extends Component {
modalConfirmLoading: false, modalConfirmLoading: false,
selectedRow: undefined, selectedRow: undefined,
selectedRowKeys: [], selectedRowKeys: [],
logPending: false,
logs: [] logs: []
}; };
@ -159,11 +160,19 @@ class Job extends Component {
}); });
}; };
showModal(title, assets = null) { showModal(title, obj = null) {
if (obj['func'] === 'shell-job') {
obj['shell'] = JSON.parse(obj['metadata'])['shell'];
}
if (obj['mode'] === 'custom') {
obj['resourceIds'] = obj['resourceIds'].split(',');
}
this.setState({ this.setState({
modalTitle: title, modalTitle: title,
modalVisible: true, modalVisible: true,
model: assets model: obj
}); });
}; };
@ -180,6 +189,19 @@ class Job extends Component {
modalConfirmLoading: true modalConfirmLoading: true
}); });
console.log(formData)
if (formData['func'] === 'shell-job') {
console.log(formData['shell'], JSON.stringify({'shell': formData['shell']}))
formData['metadata'] = JSON.stringify({'shell': formData['shell']});
formData['shell'] = undefined;
}
if (formData['mode'] === 'custom') {
let resourceIds = formData['resourceIds'];
formData['resourceIds'] = resourceIds.join(',');
}
if (formData.id) { if (formData.id) {
// 向后台提交数据 // 向后台提交数据
const result = await request.put('/jobs/' + formData.id, formData); const result = await request.put('/jobs/' + formData.id, formData);
@ -284,7 +306,9 @@ class Job extends Component {
render: (func, record) => { render: (func, record) => {
switch (func) { switch (func) {
case "check-asset-status-job": case "check-asset-status-job":
return <Tag color="green">资产状态检测</Tag> return <Tag color="green">资产状态检测</Tag>;
case "shell-job":
return <Tag color="volcano">Shell脚本</Tag>
} }
} }
}, { }, {
@ -307,6 +331,9 @@ class Job extends Component {
dataIndex: 'updated', dataIndex: 'updated',
key: 'updated', key: 'updated',
render: (text, record) => { render: (text, record) => {
if (text === '0001-01-01 00:00:00') {
return '';
}
return ( return (
<Tooltip title={text}> <Tooltip title={text}>
{dayjs(text).fromNow()} {dayjs(text).fromNow()}
@ -330,7 +357,7 @@ class Job extends Component {
onClick={async () => { onClick={async () => {
this.setState({ this.setState({
logVisible: true, logVisible: true,
logPending: '正在加载...' logPending: true
}) })
let result = await request.get(`/jobs/${record['id']}/logs`); let result = await request.get(`/jobs/${record['id']}/logs`);
@ -401,7 +428,7 @@ class Job extends Component {
<> <>
<PageHeader <PageHeader
className="site-page-header-ghost-wrapper page-herder" className="site-page-header-ghost-wrapper page-herder"
title="定时任务" title="计划任务"
breadcrumb={{ breadcrumb={{
routes: routes, routes: routes,
itemRender: itemRender itemRender: itemRender
@ -409,7 +436,7 @@ class Job extends Component {
extra={[ extra={[
<Logout key='logout'/> <Logout key='logout'/>
]} ]}
subTitle="定时任务" subTitle="计划任务"
> >
</PageHeader> </PageHeader>
@ -444,7 +471,7 @@ class Job extends Component {
<Tooltip title="新增"> <Tooltip title="新增">
<Button type="dashed" icon={<PlusOutlined/>} <Button type="dashed" icon={<PlusOutlined/>}
onClick={() => this.showModal('新增任务', {})}> onClick={() => this.showModal('新增计划任务', {})}>
</Button> </Button>
</Tooltip> </Tooltip>
@ -548,7 +575,7 @@ class Job extends Component {
okType={'danger'} okType={'danger'}
cancelText='取消' cancelText='取消'
> >
<Timeline pending={this.state.logPending} mode={'left'}> <Spin tip='加载中...' spinning={this.state.logPending}>
<pre className='cron-log'> <pre className='cron-log'>
{ {
this.state.logs.map(item => { this.state.logs.map(item => {
@ -559,8 +586,7 @@ class Job extends Component {
}) })
} }
</pre> </pre>
</Spin>
</Timeline>
</Modal> : undefined </Modal> : undefined
} }
</Content> </Content>

View File

@ -1,9 +1,41 @@
import React from 'react'; import React, {useEffect, useState} from 'react';
import {Form, Input, Modal, Radio, Select} from "antd/lib/index"; import {Form, Input, Modal, Radio, Select, Spin} from "antd/lib/index";
import TextArea from "antd/es/input/TextArea";
import request from "../../common/request";
import {message} from "antd";
const JobModal = ({title, visible, handleOk, handleCancel, confirmLoading, model}) => { const JobModal = ({title, visible, handleOk, handleCancel, confirmLoading, model}) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
if (model.func === undefined) {
model.func = 'shell-job';
}
if (model.mode === undefined) {
model.mode = 'all';
}
let [func, setFunc] = useState(model.func);
let [mode, setMode] = useState(model.mode);
let [resources, setResources] = useState([]);
useEffect(() => {
const fetchData = async () => {
setResourcesLoading(true);
let result = await request.get('/assets?protocol=ssh');
if (result['code'] === 1) {
setResources(result['data']);
} else {
message.error(result['message'], 10);
}
setResourcesLoading(false);
};
fetchData();
}, []);
let [resourcesLoading, setResourcesLoading] = useState(false);
const formItemLayout = { const formItemLayout = {
labelCol: {span: 6}, labelCol: {span: 6},
@ -39,9 +71,9 @@ const JobModal = ({title, visible, handleOk, handleCancel, confirmLoading, model
<Form.Item label="任务类型" name='func' rules={[{required: true, message: '请选择任务类型'}]}> <Form.Item label="任务类型" name='func' rules={[{required: true, message: '请选择任务类型'}]}>
<Select onChange={(value) => { <Select onChange={(value) => {
setFunc(value);
}}> }}>
<Select.Option value="shell">Shell脚本</Select.Option> <Select.Option value="shell-job">Shell脚本</Select.Option>
<Select.Option value="check-asset-status-job">资产状态检测</Select.Option> <Select.Option value="check-asset-status-job">资产状态检测</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
@ -50,10 +82,45 @@ const JobModal = ({title, visible, handleOk, handleCancel, confirmLoading, model
<Input autoComplete="off" placeholder="请输入任务名称"/> <Input autoComplete="off" placeholder="请输入任务名称"/>
</Form.Item> </Form.Item>
{
func === 'shell-job' ?
<Form.Item label="Shell脚本" name='shell' rules={[{required: true, message: '请输入Shell脚本'}]}>
<TextArea autoSize={{minRows: 5, maxRows: 10}} placeholder="在此处填写Shell脚本内容"/>
</Form.Item> : undefined
}
<Form.Item label="cron表达式" name='cron' rules={[{required: true, message: '请输入cron表达式'}]}> <Form.Item label="cron表达式" name='cron' rules={[{required: true, message: '请输入cron表达式'}]}>
<Input placeholder="请输入cron表达式"/> <Input placeholder="请输入cron表达式"/>
</Form.Item> </Form.Item>
<Form.Item label="资产选择" name='mode' rules={[{required: true, message: '请选择资产'}]}>
<Radio.Group onChange={async (e) => {
setMode(e.target.value);
}}>
<Radio value={'all'}>全部资产</Radio>
<Radio value={'custom'}>自定义</Radio>
</Radio.Group>
</Form.Item>
{
mode === 'custom' ?
<Spin tip='加载中...' spinning={resourcesLoading}>
<Form.Item label="已选择资产" name='resourceIds' rules={[{required: true}]}>
<Select
mode="multiple"
allowClear
placeholder="请选择资产"
>
{
resources.map(item => {
return <Select.Option key={item['id']}>{item['name']}</Select.Option>
})
}
</Select>
</Form.Item>
</Spin>
: undefined
}
</Form> </Form>
</Modal> </Modal>
) )