# 继承推送服务

pull/639/head
vapao 2023-11-07 18:14:58 +08:00
parent cfe33658e8
commit d125112cd9
15 changed files with 398 additions and 51 deletions

View File

@ -4,8 +4,10 @@
from django.views.generic import View from django.views.generic import View
from libs import json_response, JsonParser, Argument, auth from libs import json_response, JsonParser, Argument, auth
from libs.spug import Notification from libs.spug import Notification
from libs.push import get_contacts
from apps.alarm.models import Alarm, Group, Contact from apps.alarm.models import Alarm, Group, Contact
from apps.monitor.models import Detection from apps.monitor.models import Detection
from apps.setting.utils import AppSetting
import json import json
@ -55,8 +57,20 @@ class GroupView(View):
class ContactView(View): class ContactView(View):
@auth('alarm.contact.view|alarm.group.view') @auth('alarm.contact.view|alarm.group.view')
def get(self, request): def get(self, request):
contacts = Contact.objects.all() form, error = JsonParser(
return json_response(contacts) Argument('with_push', required=False),
).parse(request.GET)
if error is None:
response = []
if form.with_push:
push_key = AppSetting.get('spug_push_key')
if push_key:
response = get_contacts(push_key)
for item in Contact.objects.all():
response.append(item.to_dict())
return json_response(response)
return json_response(error=error)
@auth('alarm.contact.add|alarm.contact.edit') @auth('alarm.contact.add|alarm.contact.edit')
def post(self, request): def post(self, request):

View File

@ -37,8 +37,8 @@ class DetectionView(View):
).parse(request.body) ).parse(request.body)
if error is None: if error is None:
if set(form.notify_mode).intersection(['1', '2', '4']): if set(form.notify_mode).intersection(['1', '2', '4']):
if not AppSetting.get_default('spug_key'): if not AppSetting.get_default('spug_key') and not AppSetting.get_default('spug_push_key'):
return json_response(error='报警方式 微信、短信、邮件需要配置调用凭据(系统设置/基本设置),请配置后再启用该报警方式。') return json_response(error='报警方式 微信、短信、邮件需要配置调用凭据(系统设置/基本设置)或推送服务(系统设置/推送服务设置),请配置后再启用该报警方式。')
form.targets = json.dumps(form.targets) form.targets = json.dumps(form.targets)
form.notify_grp = json.dumps(form.notify_grp) form.notify_grp = json.dumps(form.notify_grp)

View File

@ -16,6 +16,7 @@ KEYS_DEFAULT = {
'mail_service': {}, 'mail_service': {},
'private_key': None, 'private_key': None,
'public_key': None, 'public_key': None,
'spug_push_key': None,
} }

View File

@ -12,5 +12,6 @@ urlpatterns = [
url(r'^ldap_test/$', ldap_test), url(r'^ldap_test/$', ldap_test),
url(r'^email_test/$', email_test), url(r'^email_test/$', email_test),
url(r'^mfa/$', MFAView.as_view()), url(r'^mfa/$', MFAView.as_view()),
url(r'^about/$', get_about) url(r'^about/$', get_about),
url(r'^balance/$', get_push_balance),
] ]

View File

@ -8,6 +8,7 @@ from libs import JsonParser, Argument, json_response, auth
from libs.utils import generate_random_str from libs.utils import generate_random_str
from libs.mail import Mail from libs.mail import Mail
from libs.spug import send_login_wx_code from libs.spug import send_login_wx_code
from libs.push import get_balance
from libs.mixins import AdminView from libs.mixins import AdminView
from apps.setting.utils import AppSetting from apps.setting.utils import AppSetting
from apps.setting.models import Setting, KEYS_DEFAULT from apps.setting.models import Setting, KEYS_DEFAULT
@ -36,7 +37,8 @@ class SettingView(AdminView):
class MFAView(AdminView): class MFAView(AdminView):
def get(self, request): def get(self, request):
if not request.user.wx_token: if not request.user.wx_token:
return json_response(error='检测到当前账户未配置微信Token请配置后再尝试启用MFA认证否则可能造成系统无法正常登录。') return json_response(
error='检测到当前账户未配置微信Token请配置后再尝试启用MFA认证否则可能造成系统无法正常登录。')
code = generate_random_str(6) code = generate_random_str(6)
send_login_wx_code(request.user.wx_token, code) send_login_wx_code(request.user.wx_token, code)
cache.set(f'{request.user.username}:code', code, 300) cache.set(f'{request.user.username}:code', code, 300)
@ -105,7 +107,8 @@ def email_test(request):
@auth('admin') @auth('admin')
def mfa_test(request): def mfa_test(request):
if not request.user.wx_token: if not request.user.wx_token:
return json_response(error='检测到当前账户未配置微信Token请配置后再尝试启用MFA认证否则可能造成系统无法正常登录。') return json_response(
error='检测到当前账户未配置微信Token请配置后再尝试启用MFA认证否则可能造成系统无法正常登录。')
code = generate_random_str(6) code = generate_random_str(6)
send_login_wx_code(request.user.wx_token, code) send_login_wx_code(request.user.wx_token, code)
cache.set(f'{request.user.username}:code', code, 300) cache.set(f'{request.user.username}:code', code, 300)
@ -120,3 +123,12 @@ def get_about(request):
'spug_version': settings.SPUG_VERSION, 'spug_version': settings.SPUG_VERSION,
'django_version': django.get_version() 'django_version': django.get_version()
}) })
@auth('admin')
def get_push_balance(request):
token = AppSetting.get_default('spug_push_key')
if not token:
return json_response(error='请先配置推送服务绑定账户')
res = get_balance(token)
return json_response(res)

26
spug_api/libs/push.py Normal file
View File

@ -0,0 +1,26 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
import requests
push_server = 'https://push.spug.cc'
def get_balance(token):
res = requests.get(f'{push_server}/spug/balance/', json={'token': token})
if res.status_code != 200:
raise Exception(f'status code: {res.status_code}')
res = res.json()
if res.get('error'):
raise Exception(res['error'])
return res['data']
def get_contacts(token):
try:
res = requests.post(f'{push_server}/spug/contacts/', json={'token': token})
res = res.json()
if res['data']:
return res['data']
except Exception:
return []

View File

@ -6,11 +6,23 @@ from apps.setting.utils import AppSetting
from apps.notify.models import Notify from apps.notify.models import Notify
from libs.mail import Mail from libs.mail import Mail
from libs.utils import human_datetime from libs.utils import human_datetime
from libs.push import push_server
from functools import partial
import requests import requests
import json import json
spug_server = 'https://api.spug.cc' spug_server = 'https://api.spug.cc'
notify_source = 'monitor' notify_source = 'monitor'
make_no_spug_key_notify = partial(
Notify.make_monitor_notify,
'发送报警信息失败',
'未配置报警服务调用凭据,请在系统管理/系统设置/基本设置/调用凭据中配置。'
)
make_no_push_key_notify = partial(
Notify.make_monitor_notify,
'发送报警信息失败',
'未绑定推送服务,请在系统管理/系统设置/推送服务设置中绑定推送助手账户。'
)
def send_login_wx_code(wx_token, code): def send_login_wx_code(wx_token, code):
@ -33,7 +45,7 @@ class Notification:
self.message = message self.message = message
self.duration = duration self.duration = duration
self.spug_key = AppSetting.get_default('spug_key') self.spug_key = AppSetting.get_default('spug_key')
self.u_ids = [] self.spug_push_key = AppSetting.get_default('spug_push_key')
@staticmethod @staticmethod
def handle_request(url, data, mode=None): def handle_request(url, data, mode=None):
@ -62,7 +74,7 @@ class Notification:
def monitor_by_wx(self, users): def monitor_by_wx(self, users):
if not self.spug_key: if not self.spug_key:
Notify.make_monitor_notify('发送报警信息失败', '未配置报警服务调用凭据,请在系统管理/系统设置/基本设置/调用凭据中配置。') make_no_spug_key_notify()
return return
data = { data = {
'token': self.spug_key, 'token': self.spug_key,
@ -99,7 +111,7 @@ class Notification:
} }
self.handle_request(f'{spug_server}/apis/notify/mail/', data, 'spug') self.handle_request(f'{spug_server}/apis/notify/mail/', data, 'spug')
else: else:
Notify.make_monitor_notify('发送报警信息失败', '未配置报警服务调用凭据,请在系统管理/系统设置/报警服务设置中配置。') make_no_spug_key_notify()
def monitor_by_dd(self, users): def monitor_by_dd(self, users):
texts = [ texts = [
@ -144,30 +156,82 @@ class Notification:
for url in users: for url in users:
self.handle_request(url, data, 'wx') self.handle_request(url, data, 'wx')
def monitor_by_spug_push(self, targets):
if not self.spug_push_key:
make_no_push_key_notify()
return
data = {
'token': self.spug_push_key,
'targets': list(targets),
'dataset': {
'title': self.title,
'target': self.target,
'message': self.message,
'duration': self.duration,
'event': self.event
}
}
self.handle_request(f'{push_server}/spug/message/', data, 'spug')
def dispatch_monitor(self, modes): def dispatch_monitor(self, modes):
self.u_ids = sum([json.loads(x.contacts) for x in Group.objects.filter(id__in=self.grp)], []) u_ids, push_ids = [], []
for item in Group.objects.filter(id__in=self.grp):
for x in json.loads(item.contacts):
if isinstance(x, str) and '_' in x:
push_ids.append(x)
else:
u_ids.append(x)
targets = set()
for mode in modes: for mode in modes:
if mode == '1': if mode == '1':
users = set(x.wx_token for x in Contact.objects.filter(id__in=self.u_ids, wx_token__isnull=False)) wx_mp_ids = set(x for x in push_ids if x.startswith('wx_mp_'))
targets.update(wx_mp_ids)
users = set(x.wx_token for x in Contact.objects.filter(id__in=u_ids, wx_token__isnull=False))
if not users: if not users:
Notify.make_monitor_notify('发送报警信息失败', '未找到可用的通知对象请确保设置了相关报警联系人的微信Token。') if not wx_mp_ids:
Notify.make_monitor_notify(
'发送报警信息失败',
'未找到可用的通知对象请确保设置了相关报警联系人的微信Token。'
)
continue continue
self.monitor_by_wx(users) self.monitor_by_wx(users)
elif mode == '2':
sms_ids = set(x for x in push_ids if x.startswith('sms_'))
targets.update(sms_ids)
elif mode == '3': elif mode == '3':
users = set(x.ding for x in Contact.objects.filter(id__in=self.u_ids, ding__isnull=False)) users = set(x.ding for x in Contact.objects.filter(id__in=u_ids, ding__isnull=False))
if not users: if not users:
Notify.make_monitor_notify('发送报警信息失败', '未找到可用的通知对象,请确保设置了相关报警联系人的钉钉。') Notify.make_monitor_notify(
'发送报警信息失败',
'未找到可用的通知对象,请确保设置了相关报警联系人的钉钉。'
)
continue continue
self.monitor_by_dd(users) self.monitor_by_dd(users)
elif mode == '4': elif mode == '4':
users = set(x.email for x in Contact.objects.filter(id__in=self.u_ids, email__isnull=False)) mail_ids = set(x for x in push_ids if x.startswith('mail_'))
targets.update(mail_ids)
users = set(x.email for x in Contact.objects.filter(id__in=u_ids, email__isnull=False))
if not users: if not users:
Notify.make_monitor_notify('发送报警信息失败', '未找到可用的通知对象,请确保设置了相关报警联系人的邮件地址。') if not mail_ids:
Notify.make_monitor_notify(
'发送报警信息失败',
'未找到可用的通知对象,请确保设置了相关报警联系人的邮件地址。'
)
continue continue
self.monitor_by_email(users) self.monitor_by_email(users)
elif mode == '5': elif mode == '5':
users = set(x.qy_wx for x in Contact.objects.filter(id__in=self.u_ids, qy_wx__isnull=False)) users = set(x.qy_wx for x in Contact.objects.filter(id__in=u_ids, qy_wx__isnull=False))
if not users: if not users:
Notify.make_monitor_notify('发送报警信息失败', '未找到可用的通知对象,请确保设置了相关报警联系人的企业微信。') Notify.make_monitor_notify(
'发送报警信息失败',
'未找到可用的通知对象,请确保设置了相关报警联系人的企业微信。'
)
continue continue
self.monitor_by_qy_wx(users) self.monitor_by_qy_wx(users)
elif mode == '6':
voice_ids = set(x for x in push_ids if x.startswith('voice_'))
targets.update(voice_ids)
if targets:
self.monitor_by_spug_push(targets)

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React from 'react'
function Link(props) {
return (
<a target="_blank" rel="noopener noreferrer" href={props.href}>{props.title}</a>
)
}
export default Link

View File

@ -16,6 +16,7 @@ import TableCard from './TableCard';
import Breadcrumb from './Breadcrumb'; import Breadcrumb from './Breadcrumb';
import AppSelector from './AppSelector'; import AppSelector from './AppSelector';
import NotFound from './NotFound'; import NotFound from './NotFound';
import Link from './Link';
export { export {
StatisticsCard, StatisticsCard,
@ -31,4 +32,5 @@ export {
Breadcrumb, Breadcrumb,
AppSelector, AppSelector,
NotFound, NotFound,
Link,
} }

View File

@ -3,16 +3,24 @@
* Copyright (c) <spug.dev@gmail.com> * Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License. * Released under the AGPL-3.0 License.
*/ */
import React, { useState } from 'react'; import React, {useEffect, useState} from 'react';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import { Modal, Form, Input, Transfer, message } from 'antd'; import {Modal, Form, Input, Transfer, Spin, message} from 'antd';
import http from 'libs/http'; import http from 'libs/http';
import store from './store'; import store from './store';
import contactStore from '../contact/store';
export default observer(function () { export default observer(function () {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [contacts, setContacts] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
useEffect(() => {
setFetching(true)
http.get('/api/alarm/contact/?with_push=1')
.then(res => setContacts(res))
.finally(() => setFetching(false))
}, []);
function handleSubmit() { function handleSubmit() {
setLoading(true); setLoading(true);
@ -42,14 +50,16 @@ export default observer(function () {
<Form.Item name="desc" label="备注信息"> <Form.Item name="desc" label="备注信息">
<Input.TextArea placeholder="请输入备注信息"/> <Input.TextArea placeholder="请输入备注信息"/>
</Form.Item> </Form.Item>
<Spin spinning={fetching}>
<Form.Item required name="contacts" valuePropName="targetKeys" label="选择联系人"> <Form.Item required name="contacts" valuePropName="targetKeys" label="选择联系人">
<Transfer <Transfer
rowKey={item => item.id} rowKey={item => item.id}
titles={['已有联系人', '已选联系人']} titles={['已有联系人', '已选联系人']}
listStyle={{width: 199}} listStyle={{width: 199}}
dataSource={contactStore.records} dataSource={contacts}
render={item => item.name}/> render={item => item.name}/>
</Form.Item> </Form.Item>
</Spin>
</Form> </Form>
</Modal> </Modal>
) )

View File

@ -11,7 +11,6 @@ import { Action, TableCard, AuthButton } from 'components';
import { http, hasPermission } from 'libs'; import { http, hasPermission } from 'libs';
import store from './store'; import store from './store';
import contactStore from '../contact/store'; import contactStore from '../contact/store';
import lds from 'lodash';
@observer @observer
class ComTable extends React.Component { class ComTable extends React.Component {
@ -76,8 +75,7 @@ class ComTable extends React.Component {
pageSizeOptions: ['10', '20', '50', '100'] pageSizeOptions: ['10', '20', '50', '100']
}}> }}>
<Table.Column title="组名称" dataIndex="name"/> <Table.Column title="组名称" dataIndex="name"/>
<Table.Column ellipsis title="成员" dataIndex="contacts" <Table.Column ellipsis title="成员" dataIndex="contacts" render={value => `${value.length}`}/>
render={value => value.map(x => lds.get(this.state.contactMap, `${x}.name`)).join(',')}/>
<Table.Column ellipsis title="描述信息" dataIndex="desc"/> <Table.Column ellipsis title="描述信息" dataIndex="desc"/>
{hasPermission('alarm.group.edit|alarm.group.del') && ( {hasPermission('alarm.group.edit|alarm.group.del') && (
<Table.Column title="操作" render={info => ( <Table.Column title="操作" render={info => (

View File

@ -14,9 +14,10 @@ import lds from 'lodash';
const modeOptions = [ const modeOptions = [
{label: '微信', 'value': '1'}, {label: '微信', 'value': '1'},
{label: '短信', 'value': '2', disabled: true}, {label: '短信', 'value': '2'},
{label: '钉钉', 'value': '3'}, {label: '电话', 'value': '6'},
{label: '邮件', 'value': '4'}, {label: '邮件', 'value': '4'},
{label: '钉钉', 'value': '3'},
{label: '企业微信', 'value': '5'}, {label: '企业微信', 'value': '5'},
]; ];

View File

@ -0,0 +1,114 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, {useEffect, useState} from 'react';
import {observer} from 'mobx-react';
import {Form, Input, Button, Spin, message} from 'antd';
import {Link} from 'components';
import css from './index.module.css';
import {http, clsNames} from 'libs';
import store from './store';
export default observer(function () {
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [balance, setBalance] = useState({})
useEffect(() => {
if (store.settings.spug_push_key) {
fetchBalance()
}
}, []);
function fetchBalance() {
setFetching(true)
http.get('/api/setting/balance/')
.then(res => setBalance(res))
.finally(() => {
setLoading(false)
setFetching(false)
})
}
function handleBind() {
const spug_push_key = store.settings.spug_push_key
if (!spug_push_key) return message.error('请输入要绑定的推送助手用户ID')
setLoading(true);
http.post('/api/setting/', {data: [{key: 'spug_push_key', value: spug_push_key}]})
.then(() => {
message.success('保存成功');
store.fetchSettings();
fetchBalance()
})
.finally(() => store.loading = false)
}
const isVip = true
return (
<React.Fragment>
<div className={css.title}>推送服务设置</div>
<div style={{maxWidth: 340}}>
<Form.Item label="推送助手账户绑定" labelCol={{span: 24}} style={{marginTop: 12}}
extra={<div>请登录 <Link href="https://push.spug.cc/login" title="推送助手"/>至个人中心 /
个人设置查看用户ID注意保密该ID请勿泄漏给第三方</div>}>
<Input.Group compact>
<Input
value={store.settings.spug_push_key}
onChange={e => store.settings.spug_push_key = e.target.value}
style={{width: 'calc(100% - 100px)'}}
placeholder="请输入要绑定的推送助手用户ID"/>
<Button
type="primary"
style={{width: 80, marginLeft: 20}}
onClick={handleBind}
loading={loading}>确定</Button>
</Input.Group>
{/*<Input.Group compact>*/}
{/* <Input bordered={false} style={{width: 'calc(100% - 100px)', paddingLeft: 0}} value="32uu73******64823d"/>*/}
{/* <Button style={{width: 80, marginLeft: 20}}>解绑</Button>*/}
{/*</Input.Group>*/}
</Form.Item>
</div>
<Form.Item style={{marginTop: 24}}
extra={<div> 如需充值请至 <Link href="https://push.spug.cc/buy/sms" title="推送助手"/>具体计费规则及说明请查看推送助手官网
</div>}>
<div className={css.statistic}>
<Spin spinning={fetching}>
<div className={css.body}>
<div className={css.item}>
<div className={css.title}>短信余额</div>
<div className={css.value}>{balance.sms_balance}</div>
</div>
<div className={css.item}>
<div className={css.title}>语音余额</div>
<div className={css.value}>{balance.voice_balance}</div>
</div>
<div className={css.item}>
<div className={css.title}>邮件余额</div>
<div className={css.value}>{balance.mail_balance}</div>
{isVip ? (
<div className={clsNames(css.tips, css.active)}>+ 会员免费20封 / </div>
) : (
<div className={css.tips}>会员免费20封 / </div>
)}
</div>
<div className={css.item}>
<div className={css.title}>微信公众号余额</div>
<div className={css.value}>{balance.wx_mp_balance}</div>
{isVip ? (
<div className={clsNames(css.tips, css.active)}>+ 会员免费100条 / </div>
) : (
<div className={css.tips}>会员免费20封 / </div>
)}
</div>
</div>
</Spin>
</div>
</Form.Item>
</React.Fragment>
)
})

View File

@ -12,6 +12,7 @@ import LDAPSetting from './LDAPSetting';
import OpenService from './OpenService'; import OpenService from './OpenService';
import KeySetting from './KeySetting'; import KeySetting from './KeySetting';
import SecuritySetting from './SecuritySetting'; import SecuritySetting from './SecuritySetting';
import PushSetting from './PushSetting';
import About from './About'; import About from './About';
import styles from './index.module.css'; import styles from './index.module.css';
import store from './store'; import store from './store';
@ -49,6 +50,7 @@ class Index extends React.Component {
<Menu.Item key="security">安全设置</Menu.Item> <Menu.Item key="security">安全设置</Menu.Item>
<Menu.Item key="ldap">LDAP设置</Menu.Item> <Menu.Item key="ldap">LDAP设置</Menu.Item>
<Menu.Item key="key">密钥设置</Menu.Item> <Menu.Item key="key">密钥设置</Menu.Item>
<Menu.Item key="push">推送服务设置</Menu.Item>
<Menu.Item key="alarm">报警服务设置</Menu.Item> <Menu.Item key="alarm">报警服务设置</Menu.Item>
<Menu.Item key="service">开放服务设置</Menu.Item> <Menu.Item key="service">开放服务设置</Menu.Item>
<Menu.Item key="about">关于</Menu.Item> <Menu.Item key="about">关于</Menu.Item>
@ -59,6 +61,7 @@ class Index extends React.Component {
{selectedKeys[0] === 'security' && <SecuritySetting/>} {selectedKeys[0] === 'security' && <SecuritySetting/>}
{selectedKeys[0] === 'ldap' && <LDAPSetting/>} {selectedKeys[0] === 'ldap' && <LDAPSetting/>}
{selectedKeys[0] === 'alarm' && <AlarmSetting/>} {selectedKeys[0] === 'alarm' && <AlarmSetting/>}
{selectedKeys[0] === 'push' && <PushSetting/>}
{selectedKeys[0] === 'service' && <OpenService/>} {selectedKeys[0] === 'service' && <OpenService/>}
{selectedKeys[0] === 'key' && <KeySetting/>} {selectedKeys[0] === 'key' && <KeySetting/>}
{selectedKeys[0] === 'about' && <About/>} {selectedKeys[0] === 'about' && <About/>}

View File

@ -29,3 +29,89 @@
.keyText { .keyText {
font-family: "Bitstream Vera Sans Mono", Monaco, "Courier New", Courier, monospace; font-family: "Bitstream Vera Sans Mono", Monaco, "Courier New", Courier, monospace;
} }
.statistic {
background: #fafafa;
border-radius: 4px;
.body {
display: flex;
flex-direction: row;
.item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 180px;
height: 150px;
&:nth-child(n+2) {
&:before {
content: ' ';
position: absolute;
top: 52px;
left: 0;
width: 1px;
height: 56px;
background: #CCCCCC;
opacity: 0.5;
}
}
.title {
font-size: 14px;
color: #666666;
margin-bottom: 6px;
}
.value {
font-size: 40px;
line-height: 46px;
color: #333333;
position: relative;
}
.tips {
position: absolute;
bottom: 16px;
font-size: 11px;
color: rgba(0, 0, 0, 0.35);
background: rgba(0, 0, 0, 0.04);
border-radius: 10px;
line-height: 20px;
text-align: center;
padding: 0 8px;
cursor: pointer;
}
.active {
cursor: initial;
background: #f7af40;
color: #ffffff;
}
}
.buy {
position: absolute;
right: 51px;
top: 110px;
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
width: 96px;
padding: 0 16px 0 20px;
border-radius: 16px;
color: #2563fc;
font-size: 14px;
cursor: pointer;
:global(.iconfont) {
font-size: 14px;
}
}
}
}