Import quantdinger_vue from feature-multi-user-postgresql
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="antd-pro-components-article-list-content-index-listContent">
|
||||
<div class="description">
|
||||
<slot>
|
||||
{{ description }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="extra">
|
||||
<a-avatar :src="avatar" size="small" />
|
||||
<a :href="href">{{ owner }}</a> 发布在 <a :href="href">{{ href }}</a>
|
||||
<em>{{ updateAt | moment }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ArticleListContent',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'antd-pro-components-article-list-content-index-listContent'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
owner: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
updateAt: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../index.less';
|
||||
|
||||
.antd-pro-components-article-list-content-index-listContent {
|
||||
.description {
|
||||
max-width: 720px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.extra {
|
||||
margin-top: 16px;
|
||||
color: @text-color-secondary;
|
||||
line-height: 22px;
|
||||
|
||||
& :deep(.ant-avatar) {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
& > em {
|
||||
margin-left: 16px;
|
||||
color: @disabled-color;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: @screen-xs) {
|
||||
.antd-pro-components-article-list-content-index-listContent {
|
||||
.extra {
|
||||
& > em {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
import ArticleListContent from './ArticleListContent'
|
||||
|
||||
export default ArticleListContent
|
||||
@@ -0,0 +1,25 @@
|
||||
import PropTypes from 'ant-design-vue/es/_util/vue-types'
|
||||
import { Tooltip, Avatar } from 'ant-design-vue'
|
||||
import { getSlotOptions } from 'ant-design-vue/lib/_util/props-util'
|
||||
import { warning } from 'ant-design-vue/lib/vc-util/warning'
|
||||
|
||||
export const AvatarListItemProps = {
|
||||
tips: PropTypes.string,
|
||||
src: PropTypes.string.def('')
|
||||
}
|
||||
|
||||
const Item = {
|
||||
__ANT_AVATAR_CHILDREN: true,
|
||||
name: 'AvatarListItem',
|
||||
props: AvatarListItemProps,
|
||||
created () {
|
||||
warning(getSlotOptions(this.$parent).__ANT_AVATAR_LIST, 'AvatarListItem must be a subcomponent of AvatarList')
|
||||
},
|
||||
render () {
|
||||
const size = this.$parent.size === 'mini' ? 'small' : this.$parent.size
|
||||
const AvatarDom = <Avatar size={size || 'small'} src={this.src} />
|
||||
return (this.tips && <Tooltip title={this.tips}>{AvatarDom}</Tooltip>) || <AvatarDom />
|
||||
}
|
||||
}
|
||||
|
||||
export default Item
|
||||
@@ -0,0 +1,72 @@
|
||||
import './index.less'
|
||||
|
||||
import PropTypes from 'ant-design-vue/es/_util/vue-types'
|
||||
import Avatar from 'ant-design-vue/es/avatar'
|
||||
import Item from './Item.jsx'
|
||||
import { filterEmpty } from '@/components/_util/util'
|
||||
|
||||
/**
|
||||
* size: `number`、 `large`、`small`、`default` 默认值: default
|
||||
* maxLength: number
|
||||
* excessItemsStyle: CSSProperties
|
||||
*/
|
||||
const AvatarListProps = {
|
||||
prefixCls: PropTypes.string.def('ant-pro-avatar-list'),
|
||||
size: {
|
||||
validator: val => {
|
||||
return typeof val === 'number' || ['small', 'large', 'default'].includes(val)
|
||||
},
|
||||
default: 'default'
|
||||
},
|
||||
maxLength: PropTypes.number.def(0),
|
||||
excessItemsStyle: PropTypes.object.def({
|
||||
color: '#f56a00',
|
||||
backgroundColor: '#fde3cf'
|
||||
})
|
||||
}
|
||||
|
||||
const AvatarList = {
|
||||
__ANT_AVATAR_LIST: true,
|
||||
Item,
|
||||
name: 'AvatarList',
|
||||
props: AvatarListProps,
|
||||
render (h) {
|
||||
const { prefixCls, size } = this.$props
|
||||
const className = {
|
||||
[`${prefixCls}`]: true,
|
||||
[`${size}`]: true
|
||||
}
|
||||
|
||||
const items = filterEmpty(this.$slots.default)
|
||||
const itemsDom = items && items.length ? <ul class={`${prefixCls}-items`}>{this.getItems(items)}</ul> : null
|
||||
return (
|
||||
<div class={className}>
|
||||
{itemsDom}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
getItems (items) {
|
||||
const className = {
|
||||
[`${this.prefixCls}-item`]: true,
|
||||
[`${this.size}`]: true
|
||||
}
|
||||
const totalSize = items.length
|
||||
|
||||
if (this.maxLength > 0) {
|
||||
items = items.slice(0, this.maxLength)
|
||||
items.push((<Avatar size={this.size === 'mini' ? 'small' : this.size} style={this.excessItemsStyle}>{`+${totalSize - this.maxLength}`}</Avatar>))
|
||||
}
|
||||
return items.map((item) => (
|
||||
<li class={className}>{item}</li>
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AvatarList.install = function (Vue) {
|
||||
Vue.component(AvatarList.name, AvatarList)
|
||||
Vue.component(AvatarList.Item.name, AvatarList.Item)
|
||||
}
|
||||
|
||||
export default AvatarList
|
||||
@@ -0,0 +1,9 @@
|
||||
import AvatarList from './List'
|
||||
import Item from './Item'
|
||||
|
||||
export {
|
||||
AvatarList,
|
||||
Item as AvatarListItem
|
||||
}
|
||||
|
||||
export default AvatarList
|
||||
@@ -0,0 +1,59 @@
|
||||
@import '../index';
|
||||
|
||||
@avatar-list-prefix-cls: ~"@{ant-pro-prefix}-avatar-list";
|
||||
@avatar-list-item-prefix-cls: ~"@{ant-pro-prefix}-avatar-list-item";
|
||||
|
||||
.@{avatar-list-prefix-cls} {
|
||||
display: inline-block;
|
||||
|
||||
ul {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
margin: 0 0 0 8px;
|
||||
font-size: 0;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.@{avatar-list-item-prefix-cls} {
|
||||
display: inline-block;
|
||||
width: @avatar-size-base;
|
||||
height: @avatar-size-base;
|
||||
margin-left: -8px;
|
||||
font-size: @font-size-base;
|
||||
|
||||
:global {
|
||||
.ant-avatar {
|
||||
cursor: pointer;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.large {
|
||||
width: @avatar-size-lg;
|
||||
height: @avatar-size-lg;
|
||||
}
|
||||
|
||||
&.small {
|
||||
width: @avatar-size-sm;
|
||||
height: @avatar-size-sm;
|
||||
}
|
||||
|
||||
&.mini {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
:global {
|
||||
.ant-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
|
||||
.ant-avatar-string {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# AvatarList 用户头像列表
|
||||
|
||||
|
||||
一组用户头像,常用在项目/团队成员列表。可通过设置 `size` 属性来指定头像大小。
|
||||
|
||||
|
||||
|
||||
引用方式:
|
||||
|
||||
```javascript
|
||||
import AvatarList from '@/components/AvatarList'
|
||||
const AvatarListItem = AvatarList.Item
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AvatarList,
|
||||
AvatarListItem
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 代码演示 [demo](https://pro.loacg.com/test/home)
|
||||
|
||||
```html
|
||||
<avatar-list size="mini">
|
||||
<avatar-list-item tips="Jake" src="https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png" />
|
||||
<avatar-list-item tips="Andy" src="https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png" />
|
||||
<avatar-list-item tips="Niko" src="https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png" />
|
||||
</avatar-list>
|
||||
```
|
||||
或
|
||||
```html
|
||||
<avatar-list :max-length="3">
|
||||
<avatar-list-item tips="Jake" src="https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png" />
|
||||
<avatar-list-item tips="Andy" src="https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png" />
|
||||
<avatar-list-item tips="Niko" src="https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png" />
|
||||
<avatar-list-item tips="Niko" src="https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png" />
|
||||
<avatar-list-item tips="Niko" src="https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png" />
|
||||
<avatar-list-item tips="Niko" src="https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png" />
|
||||
<avatar-list-item tips="Niko" src="https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png" />
|
||||
</avatar-list>
|
||||
```
|
||||
|
||||
|
||||
|
||||
## API
|
||||
|
||||
### AvatarList
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| ---------------- | -------- | ---------------------------------- | --------- |
|
||||
| size | 头像大小 | `large`、`small` 、`mini`, `default` | `default` |
|
||||
| maxLength | 要显示的最大项目 | number | - |
|
||||
| excessItemsStyle | 多余的项目风格 | CSSProperties | - |
|
||||
|
||||
### AvatarList.Item
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| ---- | ------ | --------- | --- |
|
||||
| tips | 头像展示文案 | string | - |
|
||||
| src | 头像图片连接 | string | - |
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div :style="{ padding: '0 0 32px 32px' }">
|
||||
<h4 :style="{ marginBottom: '20px' }">{{ title }}</h4>
|
||||
<v-chart
|
||||
height="254"
|
||||
:data="data"
|
||||
:forceFit="true"
|
||||
:padding="['auto', 'auto', '40', '50']">
|
||||
<v-tooltip />
|
||||
<v-axis />
|
||||
<v-bar position="x*y"/>
|
||||
</v-chart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Bar',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return []
|
||||
}
|
||||
},
|
||||
scale: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [{
|
||||
dataKey: 'x',
|
||||
min: 2
|
||||
}, {
|
||||
dataKey: 'y',
|
||||
title: '时间',
|
||||
min: 1,
|
||||
max: 22
|
||||
}]
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [
|
||||
'x*y',
|
||||
(x, y) => ({
|
||||
name: x,
|
||||
value: y
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<a-card :loading="loading" :body-style="{ padding: '20px 24px 8px' }" :bordered="false">
|
||||
<div class="chart-card-header">
|
||||
<div class="meta">
|
||||
<span class="chart-card-title">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</span>
|
||||
<span class="chart-card-action">
|
||||
<slot name="action"></slot>
|
||||
</span>
|
||||
</div>
|
||||
<div class="total">
|
||||
<slot name="total">
|
||||
<span>{{ typeof total === 'function' && total() || total }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card-content">
|
||||
<div class="content-fix">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card-footer">
|
||||
<div class="field">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ChartCard',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
total: {
|
||||
type: [Function, Number, String],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.chart-card-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
.meta {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
color: rgba(0, 0, 0, .45);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-card-action {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.chart-card-footer {
|
||||
border-top: 1px solid #e8e8e8;
|
||||
padding-top: 9px;
|
||||
margin-top: 8px;
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-card-content {
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
height: 46px;
|
||||
width: 100%;
|
||||
|
||||
.content-fix {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
color: #000;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
font-size: 30px;
|
||||
line-height: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-chart
|
||||
:forceFit="true"
|
||||
:height="height"
|
||||
:width="width"
|
||||
:data="data"
|
||||
:scale="scale"
|
||||
:padding="0">
|
||||
<v-tooltip />
|
||||
<v-interval
|
||||
:shape="['liquid-fill-gauge']"
|
||||
position="transfer*value"
|
||||
color=""
|
||||
:v-style="{
|
||||
lineWidth: 10,
|
||||
opacity: 0.75
|
||||
}"
|
||||
:tooltip="[
|
||||
'transfer*value',
|
||||
(transfer, value) => {
|
||||
return {
|
||||
name: transfer,
|
||||
value,
|
||||
};
|
||||
},
|
||||
]"
|
||||
></v-interval>
|
||||
<v-guide
|
||||
v-for="(row, index) in data"
|
||||
:key="index"
|
||||
type="text"
|
||||
:top="true"
|
||||
:position="{
|
||||
gender: row.transfer,
|
||||
value: 45
|
||||
}"
|
||||
:content="row.value + '%'"
|
||||
:v-style="{
|
||||
fontSize: 100,
|
||||
textAlign: 'center',
|
||||
opacity: 0.75,
|
||||
}"
|
||||
/>
|
||||
</v-chart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Liquid',
|
||||
props: {
|
||||
height: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="antv-chart-mini">
|
||||
<div class="chart-wrapper" :style="{ height: 46 }">
|
||||
<v-chart :force-fit="true" :height="height" :data="data" :padding="[36, 0, 18, 0]">
|
||||
<v-tooltip />
|
||||
<v-smooth-area position="x*y" />
|
||||
</v-chart>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
const data = []
|
||||
const beginDay = new Date().getTime()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
data.push({
|
||||
x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'),
|
||||
y: Math.round(Math.random() * 10)
|
||||
})
|
||||
}
|
||||
|
||||
const tooltip = [
|
||||
'x*y',
|
||||
(x, y) => ({
|
||||
name: x,
|
||||
value: y
|
||||
})
|
||||
]
|
||||
const scale = [{
|
||||
dataKey: 'x',
|
||||
min: 2
|
||||
}, {
|
||||
dataKey: 'y',
|
||||
title: '时间',
|
||||
min: 1,
|
||||
max: 22
|
||||
}]
|
||||
|
||||
export default {
|
||||
name: 'MiniArea',
|
||||
data () {
|
||||
return {
|
||||
data,
|
||||
tooltip,
|
||||
scale,
|
||||
height: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import "chart";
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="antv-chart-mini">
|
||||
<div class="chart-wrapper" :style="{ height: 46 }">
|
||||
<v-chart :force-fit="true" :height="height" :data="data" :padding="[36, 5, 18, 5]">
|
||||
<v-tooltip />
|
||||
<v-bar position="x*y" />
|
||||
</v-chart>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
const data = []
|
||||
const beginDay = new Date().getTime()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
data.push({
|
||||
x: moment(new Date(beginDay + 1000 * 60 * 60 * 24 * i)).format('YYYY-MM-DD'),
|
||||
y: Math.round(Math.random() * 10)
|
||||
})
|
||||
}
|
||||
|
||||
const tooltip = [
|
||||
'x*y',
|
||||
(x, y) => ({
|
||||
name: x,
|
||||
value: y
|
||||
})
|
||||
]
|
||||
|
||||
const scale = [{
|
||||
dataKey: 'x',
|
||||
min: 2
|
||||
}, {
|
||||
dataKey: 'y',
|
||||
title: '时间',
|
||||
min: 1,
|
||||
max: 30
|
||||
}]
|
||||
|
||||
export default {
|
||||
name: 'MiniBar',
|
||||
data () {
|
||||
return {
|
||||
data,
|
||||
tooltip,
|
||||
scale,
|
||||
height: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import "chart";
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="chart-mini-progress">
|
||||
<div class="target" :style="{ left: target + '%'}">
|
||||
<span :style="{ backgroundColor: color }" />
|
||||
<span :style="{ backgroundColor: color }"/>
|
||||
</div>
|
||||
<div class="progress-wrapper">
|
||||
<div class="progress" :style="{ backgroundColor: color, width: percentage + '%', height: height }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MiniProgress',
|
||||
props: {
|
||||
target: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '10px'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#13C2C2'
|
||||
},
|
||||
percentage: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.chart-mini-progress {
|
||||
padding: 5px 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.target {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
span {
|
||||
border-radius: 100px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
width: 2px;
|
||||
|
||||
&:last-child {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.progress-wrapper {
|
||||
background-color: #f5f5f5;
|
||||
position: relative;
|
||||
|
||||
.progress {
|
||||
transition: all .4s cubic-bezier(.08,.82,.17,1) 0s;
|
||||
border-radius: 1px 0 0 1px;
|
||||
background-color: #1890ff;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div :class="prefixCls">
|
||||
<div class="chart-wrapper" :style="{ height: 46 }">
|
||||
<v-chart :force-fit="true" :height="100" :data="dataSource" :scale="scale" :padding="[36, 0, 18, 0]">
|
||||
<v-tooltip />
|
||||
<v-smooth-line position="x*y" :size="2" />
|
||||
<v-smooth-area position="x*y" />
|
||||
</v-chart>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MiniSmoothArea',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-smooth-area'
|
||||
},
|
||||
scale: {
|
||||
type: [Object, Array],
|
||||
required: true
|
||||
},
|
||||
dataSource: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
height: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import "smooth.area.less";
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<v-chart :forceFit="true" height="400" :data="data" :padding="[20, 20, 95, 20]" :scale="scale">
|
||||
<v-tooltip></v-tooltip>
|
||||
<v-axis :dataKey="axis1Opts.dataKey" :line="axis1Opts.line" :tickLine="axis1Opts.tickLine" :grid="axis1Opts.grid" />
|
||||
<v-axis :dataKey="axis2Opts.dataKey" :line="axis2Opts.line" :tickLine="axis2Opts.tickLine" :grid="axis2Opts.grid" />
|
||||
<v-legend dataKey="user" marker="circle" :offset="30" />
|
||||
<v-coord type="polar" radius="0.8" />
|
||||
<v-line position="item*score" color="user" :size="2" />
|
||||
<v-point position="item*score" color="user" :size="4" shape="circle" />
|
||||
</v-chart>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const axis1Opts = {
|
||||
dataKey: 'item',
|
||||
line: null,
|
||||
tickLine: null,
|
||||
grid: {
|
||||
lineStyle: {
|
||||
lineDash: null
|
||||
},
|
||||
hideFirstLine: false
|
||||
}
|
||||
}
|
||||
const axis2Opts = {
|
||||
dataKey: 'score',
|
||||
line: null,
|
||||
tickLine: null,
|
||||
grid: {
|
||||
type: 'polygon',
|
||||
lineStyle: {
|
||||
lineDash: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scale = [
|
||||
{
|
||||
dataKey: 'score',
|
||||
min: 0,
|
||||
max: 80
|
||||
}, {
|
||||
dataKey: 'user',
|
||||
alias: '类型'
|
||||
}
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'Radar',
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
axis1Opts,
|
||||
axis2Opts,
|
||||
scale
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="rank">
|
||||
<h4 class="title">{{ title }}</h4>
|
||||
<ul class="list">
|
||||
<li :key="index" v-for="(item, index) in list">
|
||||
<span :class="index < 3 ? 'active' : null">{{ index + 1 }}</span>
|
||||
<span>{{ item.name }}</span>
|
||||
<span>{{ item.total }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RankList',
|
||||
// ['title', 'list']
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
list: {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
.rank {
|
||||
padding: 0 32px 32px 72px;
|
||||
|
||||
.list {
|
||||
margin: 25px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
margin-top: 16px;
|
||||
|
||||
span {
|
||||
color: rgba(0, 0, 0, .65);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
|
||||
&:first-child {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-right: 24px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
&.active {
|
||||
background-color: #314659;
|
||||
color: #fff;
|
||||
}
|
||||
&:last-child {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile .rank {
|
||||
padding: 0 32px 32px 32px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<v-chart :width="width" :height="height" :padding="[0]" :data="data" :scale="scale">
|
||||
<v-tooltip :show-title="false" />
|
||||
<v-coord type="rect" direction="TL" />
|
||||
<v-point position="x*y" color="category" shape="cloud" tooltip="value*category" />
|
||||
</v-chart>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { registerShape } from 'viser-vue'
|
||||
const DataSet = require('@antv/data-set')
|
||||
|
||||
const imgUrl = 'https://gw.alipayobjects.com/zos/rmsportal/gWyeGLCdFFRavBGIDzWk.png'
|
||||
|
||||
const scale = [
|
||||
{ dataKey: 'x', nice: false },
|
||||
{ dataKey: 'y', nice: false }
|
||||
]
|
||||
|
||||
registerShape('point', 'cloud', {
|
||||
draw (cfg, container) {
|
||||
return container.addShape('text', {
|
||||
attrs: {
|
||||
fillOpacity: cfg.opacity,
|
||||
fontSize: cfg.origin._origin.size,
|
||||
rotate: cfg.origin._origin.rotate,
|
||||
text: cfg.origin._origin.text,
|
||||
textAlign: 'center',
|
||||
fontFamily: cfg.origin._origin.font,
|
||||
fill: cfg.color,
|
||||
textBaseline: 'Alphabetic',
|
||||
...cfg.style,
|
||||
x: cfg.x,
|
||||
y: cfg.y
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default {
|
||||
name: 'TagCloud',
|
||||
props: {
|
||||
tagList: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 400
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 640
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
data: [],
|
||||
scale
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
tagList: function (val) {
|
||||
if (val.length > 0) {
|
||||
this.initTagCloud(val)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.tagList.length > 0) {
|
||||
this.initTagCloud(this.tagList)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initTagCloud (dataSource) {
|
||||
const { height, width } = this
|
||||
|
||||
const dv = new DataSet.View().source(dataSource)
|
||||
const range = dv.range('value')
|
||||
const min = range[0]
|
||||
const max = range[1]
|
||||
const imageMask = new Image()
|
||||
imageMask.crossOrigin = ''
|
||||
imageMask.src = imgUrl
|
||||
imageMask.onload = () => {
|
||||
dv.transform({
|
||||
type: 'tag-cloud',
|
||||
fields: ['name', 'value'],
|
||||
size: [width, height],
|
||||
imageMask,
|
||||
font: 'Verdana',
|
||||
padding: 0,
|
||||
timeInterval: 5000, // max execute time
|
||||
rotate () {
|
||||
let random = ~~(Math.random() * 4) % 4
|
||||
if (random === 2) {
|
||||
random = 0
|
||||
}
|
||||
return random * 90 // 0, 90, 270
|
||||
},
|
||||
fontSize (d) {
|
||||
if (d.value) {
|
||||
return ((d.value - min) / (max - min)) * (32 - 8) + 8
|
||||
}
|
||||
return 0
|
||||
}
|
||||
})
|
||||
this.data = dv.rows
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div :style="{ padding: '0 0 32px 32px' }">
|
||||
<h4 :style="{ marginBottom: '20px' }">{{ title }}</h4>
|
||||
<v-chart
|
||||
height="254"
|
||||
:data="data"
|
||||
:scale="scale"
|
||||
:forceFit="true"
|
||||
:padding="['auto', 'auto', '40', '50']">
|
||||
<v-tooltip />
|
||||
<v-axis />
|
||||
<v-bar position="x*y"/>
|
||||
</v-chart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const tooltip = [
|
||||
'x*y',
|
||||
(x, y) => ({
|
||||
name: x,
|
||||
value: y
|
||||
})
|
||||
]
|
||||
const scale = [{
|
||||
dataKey: 'x',
|
||||
title: '日期(天)',
|
||||
alias: '日期(天)',
|
||||
min: 2
|
||||
}, {
|
||||
dataKey: 'y',
|
||||
title: '流量(Gb)',
|
||||
alias: '流量(Gb)',
|
||||
min: 1
|
||||
}]
|
||||
|
||||
export default {
|
||||
name: 'Bar',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
data: [],
|
||||
scale,
|
||||
tooltip
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.getMonthBar()
|
||||
},
|
||||
methods: {
|
||||
getMonthBar () {
|
||||
this.$http.get('/analysis/month-bar')
|
||||
.then(res => {
|
||||
this.data = res.result
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="chart-trend">
|
||||
{{ term }}
|
||||
<span>{{ rate }}%</span>
|
||||
<span :class="['trend-icon', trend]"><a-icon :type="'caret-' + trend"/></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Trend',
|
||||
props: {
|
||||
term: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
percentage: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
type: {
|
||||
type: Boolean,
|
||||
default: null
|
||||
},
|
||||
target: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
fixed: {
|
||||
type: Number,
|
||||
default: 2
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
trend: this.type && 'up' || 'down',
|
||||
rate: this.percentage
|
||||
}
|
||||
},
|
||||
created () {
|
||||
const type = this.type === null ? this.value >= this.target : this.type
|
||||
this.trend = type ? 'up' : 'down'
|
||||
this.rate = (this.percentage === null ? Math.abs(this.value - this.target) * 100 / this.target : this.percentage).toFixed(this.fixed)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.chart-trend {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
|
||||
.trend-icon {
|
||||
font-size: 12px;
|
||||
|
||||
&.up, &.down {
|
||||
margin-left: 4px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
transform: scale(.83);
|
||||
}
|
||||
}
|
||||
|
||||
&.up {
|
||||
color: #f5222d;
|
||||
}
|
||||
&.down {
|
||||
color: #52c41a;
|
||||
top: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
.antv-chart-mini {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.chart-wrapper {
|
||||
position: absolute;
|
||||
bottom: -28px;
|
||||
width: 100%;
|
||||
|
||||
/* margin: 0 -5px;
|
||||
overflow: hidden; */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@import '../index';
|
||||
|
||||
@smoothArea-prefix-cls: ~"@{ant-pro-prefix}-smooth-area";
|
||||
|
||||
.@{smoothArea-prefix-cls} {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.chart-wrapper {
|
||||
position: absolute;
|
||||
bottom: -28px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Modal from 'ant-design-vue/es/modal'
|
||||
export default (Vue) => {
|
||||
function dialog (component, componentProps, modalProps) {
|
||||
const _vm = this
|
||||
modalProps = modalProps || {}
|
||||
if (!_vm || !_vm._isVue) {
|
||||
return
|
||||
}
|
||||
let dialogDiv = document.querySelector('body>div[type=dialog]')
|
||||
if (!dialogDiv) {
|
||||
dialogDiv = document.createElement('div')
|
||||
dialogDiv.setAttribute('type', 'dialog')
|
||||
document.body.appendChild(dialogDiv)
|
||||
}
|
||||
|
||||
const handle = function (checkFunction, afterHandel) {
|
||||
if (checkFunction instanceof Function) {
|
||||
const res = checkFunction()
|
||||
if (res instanceof Promise) {
|
||||
res.then(c => {
|
||||
c && afterHandel()
|
||||
})
|
||||
} else {
|
||||
res && afterHandel()
|
||||
}
|
||||
} else {
|
||||
// checkFunction && afterHandel()
|
||||
checkFunction || afterHandel()
|
||||
}
|
||||
}
|
||||
|
||||
const dialogInstance = new Vue({
|
||||
data () {
|
||||
return {
|
||||
visible: true
|
||||
}
|
||||
},
|
||||
router: _vm.$router,
|
||||
store: _vm.$store,
|
||||
mounted () {
|
||||
this.$on('close', (v) => {
|
||||
this.handleClose()
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
handleClose () {
|
||||
handle(this.$refs._component.onCancel, () => {
|
||||
this.visible = false
|
||||
this.$refs._component.$emit('close')
|
||||
this.$refs._component.$emit('cancel')
|
||||
dialogInstance.$destroy()
|
||||
})
|
||||
},
|
||||
handleOk () {
|
||||
handle(this.$refs._component.onOK || this.$refs._component.onOk, () => {
|
||||
this.visible = false
|
||||
this.$refs._component.$emit('close')
|
||||
this.$refs._component.$emit('ok')
|
||||
dialogInstance.$destroy()
|
||||
})
|
||||
}
|
||||
},
|
||||
render: function (h) {
|
||||
const that = this
|
||||
const modalModel = modalProps && modalProps.model
|
||||
if (modalModel) {
|
||||
delete modalProps.model
|
||||
}
|
||||
const ModalProps = Object.assign({}, modalModel && { model: modalModel } || {}, {
|
||||
attrs: Object.assign({}, {
|
||||
...(modalProps.attrs || modalProps)
|
||||
}, {
|
||||
visible: this.visible
|
||||
}),
|
||||
on: Object.assign({}, {
|
||||
...(modalProps.on || modalProps)
|
||||
}, {
|
||||
ok: () => {
|
||||
that.handleOk()
|
||||
},
|
||||
cancel: () => {
|
||||
that.handleClose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const componentModel = componentProps && componentProps.model
|
||||
if (componentModel) {
|
||||
delete componentProps.model
|
||||
}
|
||||
const ComponentProps = Object.assign({}, componentModel && { model: componentModel } || {}, {
|
||||
ref: '_component',
|
||||
attrs: Object.assign({}, {
|
||||
...((componentProps && componentProps.attrs) || componentProps)
|
||||
}),
|
||||
on: Object.assign({}, {
|
||||
...((componentProps && componentProps.on) || componentProps)
|
||||
})
|
||||
})
|
||||
|
||||
return h(Modal, ModalProps, [h(component, ComponentProps)])
|
||||
}
|
||||
}).$mount(dialogDiv)
|
||||
}
|
||||
|
||||
Object.defineProperty(Vue.prototype, '$dialog', {
|
||||
get: () => {
|
||||
return function () {
|
||||
dialog.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div :class="prefixCls">
|
||||
<quill-editor
|
||||
v-model="content"
|
||||
ref="myQuillEditor"
|
||||
:options="editorOption"
|
||||
@blur="onEditorBlur($event)"
|
||||
@focus="onEditorFocus($event)"
|
||||
@ready="onEditorReady($event)"
|
||||
@change="onEditorChange($event)">
|
||||
</quill-editor>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import 'quill/dist/quill.core.css'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import 'quill/dist/quill.bubble.css'
|
||||
|
||||
import { quillEditor } from 'vue-quill-editor'
|
||||
|
||||
export default {
|
||||
name: 'QuillEditor',
|
||||
components: {
|
||||
quillEditor
|
||||
},
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-editor-quill'
|
||||
},
|
||||
// 表单校验用字段
|
||||
// eslint-disable-next-line
|
||||
value: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
content: null,
|
||||
editorOption: {
|
||||
// some quill options
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onEditorBlur (quill) {
|
||||
},
|
||||
onEditorFocus (quill) {
|
||||
},
|
||||
onEditorReady (quill) {
|
||||
},
|
||||
onEditorChange ({ quill, html, text }) {
|
||||
this.$emit('change', html)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value (val) {
|
||||
this.content = val
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import url('../index.less');
|
||||
|
||||
/* 覆盖 quill 默认边框圆角为 ant 默认圆角,用于统一 ant 组件风格 */
|
||||
.ant-editor-quill {
|
||||
line-height: initial;
|
||||
:deep(.ql-toolbar.ql-snow) {
|
||||
border-radius: @border-radius-base @border-radius-base 0 0;
|
||||
}
|
||||
:deep(.ql-container.ql-snow) {
|
||||
border-radius: 0 0 @border-radius-base @border-radius-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div :class="prefixCls">
|
||||
<div ref="editor" class="editor-wrapper"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WEditor from 'wangeditor'
|
||||
|
||||
export default {
|
||||
name: 'WangEditor',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-editor-wang'
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
value: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
editor: null,
|
||||
editorContent: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value (val) {
|
||||
this.editorContent = val
|
||||
this.editor.txt.html(val)
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.initEditor()
|
||||
},
|
||||
methods: {
|
||||
initEditor () {
|
||||
this.editor = new WEditor(this.$refs.editor)
|
||||
// this.editor.onchangeTimeout = 200
|
||||
this.editor.customConfig.onchange = (html) => {
|
||||
this.editorContent = html
|
||||
this.$emit('change', this.editorContent)
|
||||
}
|
||||
this.editor.create()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ant-editor-wang {
|
||||
.editor-wrapper {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script>
|
||||
import Tooltip from 'ant-design-vue/es/tooltip'
|
||||
import { cutStrByFullLength, getStrFullLength } from '@/components/_util/util'
|
||||
/*
|
||||
const isSupportLineClamp = document.body.style.webkitLineClamp !== undefined;
|
||||
|
||||
const TooltipOverlayStyle = {
|
||||
overflowWrap: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
};
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: 'Ellipsis',
|
||||
components: {
|
||||
Tooltip
|
||||
},
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-ellipsis'
|
||||
},
|
||||
tooltip: {
|
||||
type: Boolean
|
||||
},
|
||||
length: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
lines: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
fullWidthRecognition: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStrDom (str, fullLength) {
|
||||
return (
|
||||
<span>{ cutStrByFullLength(str, this.length) + (fullLength > this.length ? '...' : '') }</span>
|
||||
)
|
||||
},
|
||||
getTooltip (fullStr, fullLength) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<template slot="title">{ fullStr }</template>
|
||||
{ this.getStrDom(fullStr, fullLength) }
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const { tooltip, length } = this.$props
|
||||
const str = this.$slots.default.map(vNode => vNode.text).join('')
|
||||
const fullLength = getStrFullLength(str)
|
||||
const strDom = tooltip && fullLength > length ? this.getTooltip(str, fullLength) : this.getStrDom(str, fullLength)
|
||||
return (
|
||||
strDom
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
import Ellipsis from './Ellipsis'
|
||||
|
||||
export default Ellipsis
|
||||
@@ -0,0 +1,38 @@
|
||||
# Ellipsis 文本自动省略号
|
||||
|
||||
文本过长自动处理省略号,支持按照文本长度和最大行数两种方式截取。
|
||||
|
||||
|
||||
|
||||
引用方式:
|
||||
|
||||
```javascript
|
||||
import Ellipsis from '@/components/Ellipsis'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Ellipsis
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 代码演示 [demo](https://pro.loacg.com/test/home)
|
||||
|
||||
```html
|
||||
<ellipsis :length="100" tooltip>
|
||||
There were injuries alleged in three cases in 2015, and a
|
||||
fourth incident in September, according to the safety recall report. After meeting with US regulators in October, the firm decided to issue a voluntary recall.
|
||||
</ellipsis>
|
||||
```
|
||||
|
||||
|
||||
|
||||
## API
|
||||
|
||||
|
||||
参数 | 说明 | 类型 | 默认值
|
||||
----|------|-----|------
|
||||
tooltip | 移动到文本展示完整内容的提示 | boolean | -
|
||||
length | 在按照长度截取下的文本最大字符数,超过则截取省略 | number | -
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div :class="prefixCls" :style="{ width: barWidth, transition: '0.3s all' }">
|
||||
<div style="float: left">
|
||||
<slot name="extra">{{ extra }}</slot>
|
||||
</div>
|
||||
<div style="float: right">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FooterToolBar',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-footer-toolbar'
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
siderWidth: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
},
|
||||
extra: {
|
||||
type: [String, Object],
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
barWidth () {
|
||||
return this.isMobile ? undefined : `calc(100% - ${this.collapsed ? 80 : this.siderWidth || 256}px)`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
import FooterToolBar from './FooterToolBar'
|
||||
import './index.less'
|
||||
|
||||
export default FooterToolBar
|
||||
@@ -0,0 +1,23 @@
|
||||
@import '../index';
|
||||
|
||||
@footer-toolbar-prefix-cls: ~"@{ant-pro-prefix}-footer-toolbar";
|
||||
|
||||
.@{footer-toolbar-prefix-cls} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 0 24px;
|
||||
line-height: 56px;
|
||||
background: #fff;
|
||||
box-shadow: 0 -1px 2px rgb(0 0 0 / 3%);
|
||||
border-top: 1px solid #e8e8e8;
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
clear: both;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# FooterToolbar 底部工具栏
|
||||
|
||||
固定在底部的工具栏。
|
||||
|
||||
|
||||
|
||||
## 何时使用
|
||||
|
||||
固定在内容区域的底部,不随滚动条移动,常用于长页面的数据搜集和提交工作。
|
||||
|
||||
|
||||
|
||||
引用方式:
|
||||
|
||||
```javascript
|
||||
import FooterToolBar from '@/components/FooterToolbar'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FooterToolBar
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 代码演示
|
||||
|
||||
```html
|
||||
<footer-tool-bar>
|
||||
<a-button type="primary" @click="validate" :loading="loading">提交</a-button>
|
||||
</footer-tool-bar>
|
||||
```
|
||||
或
|
||||
```html
|
||||
<footer-tool-bar extra="扩展信息提示">
|
||||
<a-button type="primary" @click="validate" :loading="loading">提交</a-button>
|
||||
</footer-tool-bar>
|
||||
```
|
||||
|
||||
|
||||
## API
|
||||
|
||||
参数 | 说明 | 类型 | 默认值
|
||||
----|------|-----|------
|
||||
children (slot) | 工具栏内容,向右对齐 | - | -
|
||||
extra | 额外信息,向左对齐 | String, Object | -
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div :class="footerCls">
|
||||
<global-footer class="footer custom-render">
|
||||
<template v-slot:links>
|
||||
<a @click="showLegal = true" style="cursor: pointer;">{{ $t('user.login.legal.title') }} © 2025-2026 QuantDinger</a>
|
||||
</template>
|
||||
</global-footer>
|
||||
|
||||
<a-modal :visible="showLegal" :title="$t('user.login.legal.title')" :footer="null" @cancel="showLegal = false">
|
||||
<div :class="['legal-content', { 'legal-content-dark': isDarkTheme }]">
|
||||
{{ $t('user.login.legal.content') }}
|
||||
</div>
|
||||
<div style="margin-top: 12px; text-align: right;">
|
||||
<a-button type="primary" @click="showLegal = false">OK</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { GlobalFooter } from '@ant-design-vue/pro-layout'
|
||||
import { baseMixin } from '@/store/app-mixin'
|
||||
|
||||
export default {
|
||||
name: 'ProGlobalFooter',
|
||||
components: {
|
||||
GlobalFooter
|
||||
},
|
||||
mixins: [baseMixin],
|
||||
data () {
|
||||
return {
|
||||
showLegal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 判断是否为暗黑主题
|
||||
isDarkTheme () {
|
||||
return this.navTheme === 'dark' || this.navTheme === 'realdark'
|
||||
},
|
||||
// Footer 容器类名
|
||||
footerCls () {
|
||||
return {
|
||||
'footer-wrapper': true,
|
||||
'footer-wrapper-dark': this.isDarkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
/* 不使用 scoped,直接覆盖全局样式 */
|
||||
.footer-wrapper {
|
||||
/* 调整内间距 */
|
||||
.ant-pro-global-footer {
|
||||
padding: 4px 16px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
.ant-pro-global-footer-links {
|
||||
margin-bottom: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
.ant-pro-global-footer-copyright {
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 浅色主题(默认)- 确保文字是深色的 */
|
||||
.footer-wrapper {
|
||||
.ant-pro-global-footer {
|
||||
background: transparent !important;
|
||||
color: rgba(0, 0, 0, 0.65) !important;
|
||||
}
|
||||
|
||||
/* 链接颜色 */
|
||||
.ant-pro-global-footer-links {
|
||||
a {
|
||||
color: rgba(0, 0, 0, 0.65) !important;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 版权文字颜色 */
|
||||
.ant-pro-global-footer-copyright {
|
||||
color: rgba(0, 0, 0, 0.65) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 浅色主题(默认) */
|
||||
.legal-content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
/* 暗黑主题 - 通过组件外层类名控制 */
|
||||
.footer-wrapper-dark {
|
||||
.ant-pro-global-footer {
|
||||
background: transparent !important;
|
||||
color: rgba(255, 255, 255, 0.65) !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
/* 链接颜色 */
|
||||
.ant-pro-global-footer-links {
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.65) !important;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 版权文字颜色 */
|
||||
.ant-pro-global-footer-copyright {
|
||||
color: rgba(255, 255, 255, 0.65) !important;
|
||||
}
|
||||
|
||||
/* 弹窗内容颜色 */
|
||||
.legal-content {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<a-dropdown v-if="currentUser && currentUser.name" placement="bottomRight">
|
||||
<span class="ant-pro-account-avatar">
|
||||
<a-avatar size="small" :src="currentUser.avatar" class="antd-pro-global-header-index-avatar" />
|
||||
<span>{{ currentUser.name }}</span>
|
||||
</span>
|
||||
<template v-slot:overlay>
|
||||
<a-menu class="ant-pro-drop-down menu" :selected-keys="[]">
|
||||
<a-menu-item key="profile" @click="handleProfile">
|
||||
<a-icon type="user" />
|
||||
{{ $t('menu.profile') || 'My Profile' }}
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<a-icon type="logout" />
|
||||
{{ $t('menu.account.logout') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<span v-else>
|
||||
<a-spin size="small" :style="{ marginLeft: 8, marginRight: 8 }" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Modal } from 'ant-design-vue'
|
||||
|
||||
export default {
|
||||
name: 'AvatarDropdown',
|
||||
props: {
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
menu: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleProfile () {
|
||||
this.$router.push({ name: 'Profile' })
|
||||
},
|
||||
handleLogout (e) {
|
||||
Modal.confirm({
|
||||
title: this.$t('layouts.usermenu.dialog.title'),
|
||||
content: this.$t('layouts.usermenu.dialog.content'),
|
||||
onOk: () => {
|
||||
// return new Promise((resolve, reject) => {
|
||||
// setTimeout(Math.random() > 0.5 ? resolve : reject, 1500)
|
||||
// }).catch(() => console.log('Oops errors!'))
|
||||
return this.$store.dispatch('Logout').then(() => {
|
||||
this.$router.push({ name: 'login' })
|
||||
})
|
||||
},
|
||||
onCancel () {}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.ant-pro-drop-down {
|
||||
.action {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.ant-dropdown-menu-item {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑主题 - 下拉菜单样式 */
|
||||
body.dark .ant-dropdown-menu,
|
||||
body.realdark .ant-dropdown-menu,
|
||||
.ant-layout.dark .ant-dropdown-menu,
|
||||
.ant-layout.realdark .ant-dropdown-menu,
|
||||
.ant-pro-layout.dark .ant-dropdown-menu,
|
||||
.ant-pro-layout.realdark .ant-dropdown-menu {
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #303030;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&:hover,
|
||||
&.ant-dropdown-menu-item-selected {
|
||||
background-color: #262626;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-divider {
|
||||
background-color: #303030;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div :class="wrpCls">
|
||||
<avatar-dropdown :menu="true" :current-user="currentUser" :class="prefixCls" />
|
||||
<notice-icon :class="prefixCls" />
|
||||
<select-lang :class="prefixCls" />
|
||||
<a-tooltip :title="$t('app.setting.tooltip')">
|
||||
<span :class="prefixCls" @click="handleSettingClick">
|
||||
<a-icon type="setting" style="font-size: 16px;" />
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AvatarDropdown from './AvatarDropdown'
|
||||
import SelectLang from '@/components/SelectLang'
|
||||
import NoticeIcon from '@/components/NoticeIcon'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'RightContent',
|
||||
components: {
|
||||
AvatarDropdown,
|
||||
SelectLang,
|
||||
NoticeIcon
|
||||
},
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-global-header-index-action'
|
||||
},
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
default: () => false
|
||||
},
|
||||
topMenu: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
apiBase: 'https://api.quantdinger.com/'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleSettingClick () {
|
||||
// 触发设置抽屉显示事件
|
||||
this.$root.$emit('show-setting-drawer')
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['nickname', 'avatar']),
|
||||
currentUser () {
|
||||
return {
|
||||
name: this.nickname,
|
||||
avatar: this.avatar
|
||||
}
|
||||
},
|
||||
wrpCls () {
|
||||
return {
|
||||
'ant-pro-global-header-index-right': true,
|
||||
[`ant-pro-global-header-index-${(this.isMobile || !this.topMenu) ? 'light' : this.theme}`]: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '~ant-design-vue/es/style/themes/default.less';
|
||||
|
||||
/* 浅色主题(默认) */
|
||||
.ant-pro-global-header-index-right {
|
||||
.ant-pro-global-header-index-action {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑主题 - 强制覆盖 */
|
||||
/* 只要 body 或 layout 有 dark/realdark 类,就应用这些样式 */
|
||||
body.dark,
|
||||
body.realdark,
|
||||
.ant-layout.dark,
|
||||
.ant-layout.realdark,
|
||||
.ant-pro-layout.dark,
|
||||
.ant-pro-layout.realdark {
|
||||
/* 覆盖 Header 右侧容器内所有文本颜色 */
|
||||
.ant-pro-global-header-index-right {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
|
||||
* {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.ant-pro-global-header-index-action {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff !important;
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 头像 */
|
||||
.ant-pro-account-avatar {
|
||||
.antd-pro-global-header-index-avatar {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 下拉菜单触发器(包含图标) */
|
||||
.ant-pro-drop-down,
|
||||
.ant-dropdown-trigger {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff !important;
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div :class="prefixCls">
|
||||
<a-tabs v-model="currentTab" @change="handleTabChange">
|
||||
<a-tab-pane v-for="v in icons" :tab="v.title" :key="v.key">
|
||||
<ul>
|
||||
<li v-for="(icon, key) in v.icons" :key="`${v.key}-${key}`" :class="{ 'active': selectedIcon==icon }" @click="handleSelectedIcon(icon)" >
|
||||
<a-icon :type="icon" :style="{ fontSize: '36px' }" />
|
||||
</li>
|
||||
</ul>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import icons from './icons'
|
||||
|
||||
export default {
|
||||
name: 'IconSelect',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-icon-selector'
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
value: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
selectedIcon: this.value || '',
|
||||
currentTab: 'directional',
|
||||
icons
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value (val) {
|
||||
this.selectedIcon = val
|
||||
this.autoSwitchTab()
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (this.value) {
|
||||
this.autoSwitchTab()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleSelectedIcon (icon) {
|
||||
this.selectedIcon = icon
|
||||
this.$emit('change', icon)
|
||||
},
|
||||
handleTabChange (activeKey) {
|
||||
this.currentTab = activeKey
|
||||
},
|
||||
autoSwitchTab () {
|
||||
icons.some(item => item.icons.some(icon => icon === this.value) && (this.currentTab = item.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import "../index.less";
|
||||
|
||||
ul{
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
overflow-y: scroll;
|
||||
height: 250px;
|
||||
|
||||
li{
|
||||
display: inline-block;
|
||||
padding: @padding-sm;
|
||||
margin: 3px 0;
|
||||
border-radius: @border-radius-base;
|
||||
|
||||
&:hover, &.active{
|
||||
// box-shadow: 0px 0px 5px 2px @primary-color;
|
||||
cursor: pointer;
|
||||
color: @white;
|
||||
background-color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
IconSelector
|
||||
====
|
||||
|
||||
> 图标选择组件,常用于为某一个数据设定一个图标时使用
|
||||
> eg: 设定菜单列表时,为每个菜单设定一个图标
|
||||
|
||||
该组件由 [@Saraka](https://github.com/saraka-tsukai) 封装
|
||||
|
||||
|
||||
|
||||
### 使用方式
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<icon-selector @change="handleIconChange"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import IconSelector from '@/components/IconSelector'
|
||||
|
||||
export default {
|
||||
name: 'YourView',
|
||||
components: {
|
||||
IconSelector
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleIconChange (icon) {
|
||||
console.log('change Icon', icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 事件
|
||||
|
||||
|
||||
| 名称 | 说明 | 类型 | 默认值 |
|
||||
| ------ | -------------------------- | ------ | ------ |
|
||||
| change | 当改变了 `icon` 选中项触发 | String | - |
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 增加新的图标时,请遵循以下数据结构
|
||||
* Adding new icon please follow the data structure below
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
key: 'directional',
|
||||
title: '方向性图标',
|
||||
icons: ['step-backward', 'step-forward', 'fast-backward', 'fast-forward', 'shrink', 'arrows-alt', 'down', 'up', 'left', 'right', 'caret-up', 'caret-down', 'caret-left', 'caret-right', 'up-circle', 'down-circle', 'left-circle', 'right-circle', 'double-right', 'double-left', 'vertical-left', 'vertical-right', 'forward', 'backward', 'rollback', 'enter', 'retweet', 'swap', 'swap-left', 'swap-right', 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'play-circle', 'up-square', 'down-square', 'left-square', 'right-square', 'login', 'logout', 'menu-fold', 'menu-unfold', 'border-bottom', 'border-horizontal', 'border-inner', 'border-left', 'border-right', 'border-top', 'border-verticle', 'pic-center', 'pic-left', 'pic-right', 'radius-bottomleft', 'radius-bottomright', 'radius-upleft', 'fullscreen', 'fullscreen-exit']
|
||||
},
|
||||
{
|
||||
key: 'suggested',
|
||||
title: '提示建议性图标',
|
||||
icons: ['question', 'question-circle', 'plus', 'plus-circle', 'pause', 'pause-circle', 'minus', 'minus-circle', 'plus-square', 'minus-square', 'info', 'info-circle', 'exclamation', 'exclamation-circle', 'close', 'close-circle', 'close-square', 'check', 'check-circle', 'check-square', 'clock-circle', 'warning', 'issues-close', 'stop']
|
||||
},
|
||||
{
|
||||
key: 'editor',
|
||||
title: '编辑类图标',
|
||||
icons: ['edit', 'form', 'copy', 'scissor', 'delete', 'snippets', 'diff', 'highlight', 'align-center', 'align-left', 'align-right', 'bg-colors', 'bold', 'italic', 'underline', 'strikethrough', 'redo', 'undo', 'zoom-in', 'zoom-out', 'font-colors', 'font-size', 'line-height', 'colum-height', 'dash', 'small-dash', 'sort-ascending', 'sort-descending', 'drag', 'ordered-list', 'radius-setting']
|
||||
},
|
||||
{
|
||||
key: 'data',
|
||||
title: '数据类图标',
|
||||
icons: ['area-chart', 'pie-chart', 'bar-chart', 'dot-chart', 'line-chart', 'radar-chart', 'heat-map', 'fall', 'rise', 'stock', 'box-plot', 'fund', 'sliders']
|
||||
},
|
||||
{
|
||||
key: 'brand_logo',
|
||||
title: '网站通用图标',
|
||||
icons: ['lock', 'unlock', 'bars', 'book', 'calendar', 'cloud', 'cloud-download', 'code', 'copy', 'credit-card', 'delete', 'desktop', 'download', 'ellipsis', 'file', 'file-text', 'file-unknown', 'file-pdf', 'file-word', 'file-excel', 'file-jpg', 'file-ppt', 'file-markdown', 'file-add', 'folder', 'folder-open', 'folder-add', 'hdd', 'frown', 'meh', 'smile', 'inbox', 'laptop', 'appstore', 'link', 'mail', 'mobile', 'notification', 'paper-clip', 'picture', 'poweroff', 'reload', 'search', 'setting', 'share-alt', 'shopping-cart', 'tablet', 'tag', 'tags', 'to-top', 'upload', 'user', 'video-camera', 'home', 'loading', 'loading-3-quarters', 'cloud-upload', 'star', 'heart', 'environment', 'eye', 'camera', 'save', 'team', 'solution', 'phone', 'filter', 'exception', 'export', 'customer-service', 'qrcode', 'scan', 'like', 'dislike', 'message', 'pay-circle', 'calculator', 'pushpin', 'bulb', 'select', 'switcher', 'rocket', 'bell', 'disconnect', 'database', 'compass', 'barcode', 'hourglass', 'key', 'flag', 'layout', 'printer', 'sound', 'usb', 'skin', 'tool', 'sync', 'wifi', 'car', 'schedule', 'user-add', 'user-delete', 'usergroup-add', 'usergroup-delete', 'man', 'woman', 'shop', 'gift', 'idcard', 'medicine-box', 'red-envelope', 'coffee', 'copyright', 'trademark', 'safety', 'wallet', 'bank', 'trophy', 'contacts', 'global', 'shake', 'api', 'fork', 'dashboard', 'table', 'profile', 'alert', 'audit', 'branches', 'build', 'border', 'crown', 'experiment', 'fire', 'money-collect', 'property-safety', 'read', 'reconciliation', 'rest', 'security-scan', 'insurance', 'interation', 'safety-certificate', 'project', 'thunderbolt', 'block', 'cluster', 'deployment-unit', 'dollar', 'euro', 'pound', 'file-done', 'file-exclamation', 'file-protect', 'file-search', 'file-sync', 'gateway', 'gold', 'robot', 'shopping']
|
||||
},
|
||||
{
|
||||
key: 'application',
|
||||
title: '品牌和标识',
|
||||
icons: ['android', 'apple', 'windows', 'ie', 'chrome', 'github', 'aliwangwang', 'dingding', 'weibo-square', 'weibo-circle', 'taobao-circle', 'html5', 'weibo', 'twitter', 'wechat', 'youtube', 'alipay-circle', 'taobao', 'skype', 'qq', 'medium-workmark', 'gitlab', 'medium', 'linkedin', 'google-plus', 'dropbox', 'facebook', 'codepen', 'code-sandbox', 'amazon', 'google', 'codepen-circle', 'alipay', 'ant-design', 'aliyun', 'zhihu', 'slack', 'slack-square', 'behance', 'behance-square', 'dribbble', 'dribbble-square', 'instagram', 'yuque', 'alibaba', 'yahoo']
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
import IconSelector from './IconSelector'
|
||||
export default IconSelector
|
||||
@@ -0,0 +1,161 @@
|
||||
<script>
|
||||
import events from './events'
|
||||
|
||||
export default {
|
||||
name: 'MultiTab',
|
||||
data () {
|
||||
return {
|
||||
fullPathList: [],
|
||||
pages: [],
|
||||
activeKey: '',
|
||||
newTabIndex: 0
|
||||
}
|
||||
},
|
||||
created () {
|
||||
// bind event
|
||||
events.$on('open', val => {
|
||||
if (!val) {
|
||||
throw new Error(`multi-tab: open tab ${val} err`)
|
||||
}
|
||||
this.activeKey = val
|
||||
}).$on('close', val => {
|
||||
if (!val) {
|
||||
this.closeThat(this.activeKey)
|
||||
return
|
||||
}
|
||||
this.closeThat(val)
|
||||
}).$on('rename', ({ key, name }) => {
|
||||
try {
|
||||
const item = this.pages.find(item => item.path === key)
|
||||
item.meta.customTitle = name
|
||||
this.$forceUpdate()
|
||||
} catch (e) {
|
||||
}
|
||||
})
|
||||
|
||||
this.pages.push(this.$route)
|
||||
this.fullPathList.push(this.$route.fullPath)
|
||||
this.selectedLastPath()
|
||||
},
|
||||
methods: {
|
||||
onEdit (targetKey, action) {
|
||||
this[action](targetKey)
|
||||
},
|
||||
remove (targetKey) {
|
||||
this.pages = this.pages.filter(page => page.fullPath !== targetKey)
|
||||
this.fullPathList = this.fullPathList.filter(path => path !== targetKey)
|
||||
// 判断当前标签是否关闭,若关闭则跳转到最后一个还存在的标签页
|
||||
if (!this.fullPathList.includes(this.activeKey)) {
|
||||
this.selectedLastPath()
|
||||
}
|
||||
},
|
||||
selectedLastPath () {
|
||||
this.activeKey = this.fullPathList[this.fullPathList.length - 1]
|
||||
},
|
||||
|
||||
// content menu
|
||||
closeThat (e) {
|
||||
// 判断是否为最后一个标签页,如果是最后一个,则无法被关闭
|
||||
if (this.fullPathList.length > 1) {
|
||||
this.remove(e)
|
||||
} else {
|
||||
this.$message.info('这是最后一个标签了, 无法被关闭')
|
||||
}
|
||||
},
|
||||
closeLeft (e) {
|
||||
const currentIndex = this.fullPathList.indexOf(e)
|
||||
if (currentIndex > 0) {
|
||||
this.fullPathList.forEach((item, index) => {
|
||||
if (index < currentIndex) {
|
||||
this.remove(item)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.$message.info('左侧没有标签')
|
||||
}
|
||||
},
|
||||
closeRight (e) {
|
||||
const currentIndex = this.fullPathList.indexOf(e)
|
||||
if (currentIndex < (this.fullPathList.length - 1)) {
|
||||
this.fullPathList.forEach((item, index) => {
|
||||
if (index > currentIndex) {
|
||||
this.remove(item)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.$message.info('右侧没有标签')
|
||||
}
|
||||
},
|
||||
closeAll (e) {
|
||||
const currentIndex = this.fullPathList.indexOf(e)
|
||||
this.fullPathList.forEach((item, index) => {
|
||||
if (index !== currentIndex) {
|
||||
this.remove(item)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeMenuClick (key, route) {
|
||||
this[key](route)
|
||||
},
|
||||
renderTabPaneMenu (e) {
|
||||
return (
|
||||
<a-menu {...{ on: { click: ({ key, item, domEvent }) => { this.closeMenuClick(key, e) } } }}>
|
||||
<a-menu-item key="closeThat">关闭当前标签</a-menu-item>
|
||||
<a-menu-item key="closeRight">关闭右侧</a-menu-item>
|
||||
<a-menu-item key="closeLeft">关闭左侧</a-menu-item>
|
||||
<a-menu-item key="closeAll">关闭全部</a-menu-item>
|
||||
</a-menu>
|
||||
)
|
||||
},
|
||||
// render
|
||||
renderTabPane (title, keyPath) {
|
||||
const menu = this.renderTabPaneMenu(keyPath)
|
||||
|
||||
return (
|
||||
<a-dropdown overlay={menu} trigger={['contextmenu']}>
|
||||
<span style={{ userSelect: 'none' }}>{ title }</span>
|
||||
</a-dropdown>
|
||||
)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route': function (newVal) {
|
||||
this.activeKey = newVal.fullPath
|
||||
if (this.fullPathList.indexOf(newVal.fullPath) < 0) {
|
||||
this.fullPathList.push(newVal.fullPath)
|
||||
this.pages.push(newVal)
|
||||
}
|
||||
},
|
||||
activeKey: function (newPathKey) {
|
||||
this.$router.push({ path: newPathKey })
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const { onEdit, $data: { pages } } = this
|
||||
const panes = pages.map(page => {
|
||||
return (
|
||||
<a-tab-pane
|
||||
style={{ height: 0 }}
|
||||
tab={this.renderTabPane(page.meta.customTitle || page.meta.title, page.fullPath)}
|
||||
key={page.fullPath} closable={pages.length > 1}
|
||||
>
|
||||
</a-tab-pane>)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="ant-pro-multi-tab">
|
||||
<div class="ant-pro-multi-tab-wrapper">
|
||||
<a-tabs
|
||||
hideAdd
|
||||
type={'editable-card'}
|
||||
v-model={this.activeKey}
|
||||
tabBarStyle={{ background: '#FFF', margin: 0, paddingLeft: '16px', paddingTop: '1px' }}
|
||||
{...{ on: { edit: onEdit } }}>
|
||||
{panes}
|
||||
</a-tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
import Vue from 'vue'
|
||||
export default new Vue()
|
||||
@@ -0,0 +1,40 @@
|
||||
import events from './events'
|
||||
import MultiTab from './MultiTab'
|
||||
import './index.less'
|
||||
|
||||
const api = {
|
||||
/**
|
||||
* open new tab on route fullPath
|
||||
* @param config
|
||||
*/
|
||||
open: function (config) {
|
||||
events.$emit('open', config)
|
||||
},
|
||||
rename: function (key, name) {
|
||||
events.$emit('rename', { key: key, name: name })
|
||||
},
|
||||
/**
|
||||
* close current page
|
||||
*/
|
||||
closeCurrentPage: function () {
|
||||
this.close()
|
||||
},
|
||||
/**
|
||||
* close route fullPath tab
|
||||
* @param config
|
||||
*/
|
||||
close: function (config) {
|
||||
events.$emit('close', config)
|
||||
}
|
||||
}
|
||||
|
||||
MultiTab.install = function (Vue) {
|
||||
if (Vue.prototype.$multiTab) {
|
||||
return
|
||||
}
|
||||
api.instance = events
|
||||
Vue.prototype.$multiTab = api
|
||||
Vue.component('multi-tab', MultiTab)
|
||||
}
|
||||
|
||||
export default MultiTab
|
||||
@@ -0,0 +1,25 @@
|
||||
@import '../index';
|
||||
|
||||
@multi-tab-prefix-cls: ~"@{ant-pro-prefix}-multi-tab";
|
||||
@multi-tab-wrapper-prefix-cls: ~"@{ant-pro-prefix}-multi-tab-wrapper";
|
||||
|
||||
/*
|
||||
.topmenu .@{multi-tab-prefix-cls} {
|
||||
max-width: 1200px;
|
||||
margin: -23px auto 24px auto;
|
||||
}
|
||||
*/
|
||||
.@{multi-tab-prefix-cls} {
|
||||
margin: -23px -24px 24px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topmenu .@{multi-tab-wrapper-prefix-cls} {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topmenu.content-width-Fluid .@{multi-tab-wrapper-prefix-cls} {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@import url('../index.less');
|
||||
|
||||
/* Make clicks pass-through */
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1031;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: @primary-color;
|
||||
}
|
||||
|
||||
/* Fancy blur effect */
|
||||
#nprogress .peg {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
transform: rotate(3deg) translate(0, -4px);
|
||||
transform: rotate(3deg) translate(0, -4px);
|
||||
transform: rotate(3deg) translate(0, -4px);
|
||||
box-shadow: 0 0 10px @primary-color, 0 0 5px @primary-color;
|
||||
}
|
||||
|
||||
/* Remove these to get rid of the spinner */
|
||||
#nprogress .spinner {
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
z-index: 1031;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#nprogress .spinner-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
box-sizing: border-box;
|
||||
border: solid 2px transparent;
|
||||
border-top-color: @primary-color;
|
||||
border-left-color: @primary-color;
|
||||
border-radius: 50%;
|
||||
animation: nprogress-spinner 400ms linear infinite;
|
||||
animation: nprogress-spinner 400ms linear infinite;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent #nprogress .spinner,
|
||||
.nprogress-custom-parent #nprogress .bar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@keyframes nprogress-spinner {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes nprogress-spinner {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
<template>
|
||||
<div class="notice-icon-wrapper">
|
||||
<a-popover
|
||||
v-model="visible"
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
overlayClassName="header-notice-wrapper"
|
||||
:getPopupContainer="() => $refs.noticeRef.parentElement"
|
||||
:autoAdjustOverflow="true"
|
||||
:arrowPointAtCenter="true"
|
||||
:overlayStyle="{ width: '380px', top: '50px' }"
|
||||
>
|
||||
<template slot="content">
|
||||
<div class="notice-header">
|
||||
<span class="notice-title">{{ $t('notice.title') }}</span>
|
||||
<a v-if="notifications.length > 0" @click="markAllRead" class="notice-action">
|
||||
{{ $t('notice.markAllRead') }}
|
||||
</a>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<div class="notice-list" v-if="notifications.length > 0">
|
||||
<div
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="notice-item"
|
||||
:class="{ unread: !item.is_read }"
|
||||
@click="handleNoticeClick(item)"
|
||||
>
|
||||
<div class="notice-item-icon">
|
||||
<a-icon :type="getNoticeIcon(item.signal_type)" :style="{ color: getNoticeColor(item.signal_type) }" />
|
||||
</div>
|
||||
<div class="notice-item-content">
|
||||
<div class="notice-item-title">{{ item.title }}</div>
|
||||
<div class="notice-item-desc">{{ truncateMessage(item.message) }}</div>
|
||||
<div class="notice-item-time">{{ formatTime(item.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice-empty" v-else>
|
||||
<a-empty :description="$t('notice.empty')" />
|
||||
</div>
|
||||
</a-spin>
|
||||
<div class="notice-footer" v-if="notifications.length > 0">
|
||||
<a @click="clearNotifications">{{ $t('notice.clear') }}</a>
|
||||
</div>
|
||||
</template>
|
||||
<span @click="fetchNotice" class="header-notice" ref="noticeRef">
|
||||
<a-badge :count="unreadCount" :overflowCount="99">
|
||||
<a-icon style="font-size: 16px; padding: 4px" type="bell" />
|
||||
</a-badge>
|
||||
</span>
|
||||
</a-popover>
|
||||
|
||||
<!-- 通知详情弹窗 -->
|
||||
<a-modal
|
||||
v-model="detailVisible"
|
||||
:title="detailNotice ? detailNotice.title : ''"
|
||||
:footer="null"
|
||||
:width="isHtmlReport ? 900 : 600"
|
||||
:wrapClassName="isHtmlReport ? 'notice-detail-modal html-report-modal' : 'notice-detail-modal'"
|
||||
centered
|
||||
>
|
||||
<div v-if="detailNotice" class="notice-detail">
|
||||
<div class="notice-detail-meta">
|
||||
<div class="notice-detail-type">
|
||||
<a-icon :type="getNoticeIcon(detailNotice.signal_type)" :style="{ color: getNoticeColor(detailNotice.signal_type) }" />
|
||||
<span class="type-label">{{ getNoticeTypeLabel(detailNotice.signal_type) }}</span>
|
||||
</div>
|
||||
<div class="notice-detail-time">
|
||||
<a-icon type="clock-circle" />
|
||||
<span>{{ formatFullTime(detailNotice.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 消息内容 - 支持 HTML 报告或 Markdown 格式 -->
|
||||
<div class="notice-detail-content" :class="{ 'html-report': isHtmlReport }">
|
||||
<div v-html="formatMessageHtml(detailNotice.message)" class="message-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- 如果有额外的 payload 信息(非 HTML 报告时显示) -->
|
||||
<template v-if="!isHtmlReport && detailNotice.payload && Object.keys(detailNotice.payload).length > 0">
|
||||
<a-divider />
|
||||
<div class="notice-detail-extra">
|
||||
<div class="extra-title">{{ $t('notice.detailInfo') }}</div>
|
||||
|
||||
<!-- AI分析结果 -->
|
||||
<template v-if="detailNotice.signal_type === 'ai_monitor'">
|
||||
<div v-if="detailNotice.payload.final_decision" class="extra-item decision">
|
||||
<span class="label">{{ $t('notice.aiDecision') }}:</span>
|
||||
<a-tag :color="getDecisionColor(detailNotice.payload.final_decision)">
|
||||
{{ detailNotice.payload.final_decision }}
|
||||
</a-tag>
|
||||
<span v-if="detailNotice.payload.confidence" class="confidence">
|
||||
({{ $t('notice.confidence') }}: {{ detailNotice.payload.confidence }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="detailNotice.payload.reasoning" class="extra-item">
|
||||
<span class="label">{{ $t('notice.reasoning') }}:</span>
|
||||
<span class="value">{{ detailNotice.payload.reasoning }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 价格提醒 -->
|
||||
<template v-if="detailNotice.signal_type === 'price_alert'">
|
||||
<div v-if="detailNotice.payload.symbol" class="extra-item">
|
||||
<span class="label">{{ $t('notice.symbol') }}:</span>
|
||||
<span class="value">{{ detailNotice.payload.symbol }}</span>
|
||||
</div>
|
||||
<div v-if="detailNotice.payload.price" class="extra-item">
|
||||
<span class="label">{{ $t('notice.currentPrice') }}:</span>
|
||||
<span class="value">${{ detailNotice.payload.price }}</span>
|
||||
</div>
|
||||
<div v-if="detailNotice.payload.trigger_price" class="extra-item">
|
||||
<span class="label">{{ $t('notice.triggerPrice') }}:</span>
|
||||
<span class="value">${{ detailNotice.payload.trigger_price }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 交易信号 -->
|
||||
<template v-if="detailNotice.signal_type === 'signal' || detailNotice.signal_type === 'trade'">
|
||||
<div v-if="detailNotice.payload.symbol" class="extra-item">
|
||||
<span class="label">{{ $t('notice.symbol') }}:</span>
|
||||
<span class="value">{{ detailNotice.payload.symbol }}</span>
|
||||
</div>
|
||||
<div v-if="detailNotice.payload.action" class="extra-item">
|
||||
<span class="label">{{ $t('notice.action') }}:</span>
|
||||
<a-tag :color="detailNotice.payload.action === 'BUY' ? 'green' : 'red'">
|
||||
{{ detailNotice.payload.action }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="detailNotice.payload.quantity" class="extra-item">
|
||||
<span class="label">{{ $t('notice.quantity') }}:</span>
|
||||
<span class="value">{{ detailNotice.payload.quantity }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="notice-detail-actions">
|
||||
<a-button v-if="detailNotice.payload && detailNotice.payload.monitor_id" type="primary" @click="goToPortfolio">
|
||||
<a-icon type="fund" />
|
||||
{{ $t('notice.viewPortfolio') }}
|
||||
</a-button>
|
||||
<a-button @click="detailVisible = false">
|
||||
{{ $t('notice.close') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getStrategyNotifications } from '@/api/strategy'
|
||||
import request from '@/utils/request'
|
||||
|
||||
export default {
|
||||
name: 'HeaderNotice',
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
visible: false,
|
||||
detailVisible: false,
|
||||
detailNotice: null,
|
||||
notifications: [],
|
||||
lastFetchId: 0,
|
||||
pollingTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
unreadCount () {
|
||||
return this.notifications.filter(n => !n.is_read).length
|
||||
},
|
||||
isHtmlReport () {
|
||||
if (!this.detailNotice || !this.detailNotice.message) return false
|
||||
return this.detailNotice.message.includes('<div class="qd-report">') ||
|
||||
this.detailNotice.message.includes('<style>')
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.fetchNotifications()
|
||||
this.startPolling()
|
||||
},
|
||||
beforeDestroy () {
|
||||
this.stopPolling()
|
||||
},
|
||||
methods: {
|
||||
startPolling () {
|
||||
this.stopPolling()
|
||||
// 每30秒轮询一次
|
||||
this.pollingTimer = setInterval(() => {
|
||||
this.fetchNotifications(true)
|
||||
}, 30000)
|
||||
},
|
||||
stopPolling () {
|
||||
if (this.pollingTimer) {
|
||||
clearInterval(this.pollingTimer)
|
||||
this.pollingTimer = null
|
||||
}
|
||||
},
|
||||
async fetchNotifications (silent = false) {
|
||||
if (!silent) {
|
||||
this.loading = true
|
||||
}
|
||||
try {
|
||||
const res = await getStrategyNotifications({ limit: 50 })
|
||||
if (res.code === 1 && res.data?.items) {
|
||||
// 解析 payload_json 如果是字符串
|
||||
this.notifications = res.data.items.map(item => {
|
||||
let payload = item.payload_json
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload)
|
||||
} catch (e) {
|
||||
payload = {}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
payload,
|
||||
is_read: item.is_read === 1 || item.is_read === true
|
||||
}
|
||||
})
|
||||
if (this.notifications.length > 0) {
|
||||
this.lastFetchId = Math.max(...this.notifications.map(n => n.id))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications:', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
fetchNotice () {
|
||||
if (!this.visible) {
|
||||
this.fetchNotifications()
|
||||
}
|
||||
this.visible = !this.visible
|
||||
},
|
||||
getNoticeIcon (signalType) {
|
||||
const iconMap = {
|
||||
'ai_monitor': 'robot',
|
||||
'price_alert': 'bell',
|
||||
'signal': 'thunderbolt',
|
||||
'buy': 'rise',
|
||||
'sell': 'fall',
|
||||
'hold': 'pause-circle',
|
||||
'trade': 'swap'
|
||||
}
|
||||
return iconMap[signalType] || 'notification'
|
||||
},
|
||||
getNoticeColor (signalType) {
|
||||
const colorMap = {
|
||||
'ai_monitor': '#722ed1',
|
||||
'price_alert': '#faad14',
|
||||
'signal': '#1890ff',
|
||||
'buy': '#52c41a',
|
||||
'sell': '#f5222d',
|
||||
'hold': '#faad14',
|
||||
'trade': '#13c2c2'
|
||||
}
|
||||
return colorMap[signalType] || '#1890ff'
|
||||
},
|
||||
getNoticeTypeLabel (signalType) {
|
||||
const labelMap = {
|
||||
'ai_monitor': this.$t('notice.type.aiMonitor'),
|
||||
'price_alert': this.$t('notice.type.priceAlert'),
|
||||
'signal': this.$t('notice.type.signal'),
|
||||
'buy': this.$t('notice.type.buy'),
|
||||
'sell': this.$t('notice.type.sell'),
|
||||
'hold': this.$t('notice.type.hold'),
|
||||
'trade': this.$t('notice.type.trade')
|
||||
}
|
||||
return labelMap[signalType] || this.$t('notice.type.notification')
|
||||
},
|
||||
getDecisionColor (decision) {
|
||||
const colorMap = {
|
||||
'BUY': 'green',
|
||||
'SELL': 'red',
|
||||
'HOLD': 'orange'
|
||||
}
|
||||
return colorMap[decision] || 'blue'
|
||||
},
|
||||
truncateMessage (message) {
|
||||
if (!message) return ''
|
||||
return message.length > 80 ? message.substring(0, 80) + '...' : message
|
||||
},
|
||||
formatTime (timestamp) {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) {
|
||||
return this.$t('notice.justNow')
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes} ${this.$t('notice.minutesAgo')}`
|
||||
} else if (hours < 24) {
|
||||
return `${hours} ${this.$t('notice.hoursAgo')}`
|
||||
} else if (days < 7) {
|
||||
return `${days} ${this.$t('notice.daysAgo')}`
|
||||
} else {
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
},
|
||||
formatFullTime (timestamp) {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.toLocaleString()
|
||||
},
|
||||
formatMessageHtml (message) {
|
||||
if (!message) return ''
|
||||
|
||||
// 检查是否已经是 HTML 格式(AI Monitor 的报告)
|
||||
if (message.includes('<div class="qd-report">') || message.includes('<style>')) {
|
||||
// 已经是 HTML,直接返回
|
||||
return message
|
||||
}
|
||||
|
||||
// 简单的 Markdown 转换
|
||||
const html = message
|
||||
// 转义 HTML
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 标题
|
||||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||||
// 粗体
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
// 斜体
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// 列表项
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
// 换行
|
||||
.replace(/\n/g, '<br>')
|
||||
return html
|
||||
},
|
||||
handleNoticeClick (item) {
|
||||
// 标记为已读
|
||||
this.markAsRead(item.id)
|
||||
// 打开详情弹窗
|
||||
this.detailNotice = item
|
||||
this.detailVisible = true
|
||||
this.visible = false
|
||||
},
|
||||
goToPortfolio () {
|
||||
this.detailVisible = false
|
||||
this.$router.push({ path: '/portfolio' }).catch(() => {})
|
||||
},
|
||||
async markAsRead (id) {
|
||||
const item = this.notifications.find(n => n.id === id)
|
||||
if (item) {
|
||||
item.is_read = true
|
||||
}
|
||||
// 调用后端API标记已读
|
||||
try {
|
||||
await request({
|
||||
url: '/api/strategies/notifications/read',
|
||||
method: 'post',
|
||||
data: { id }
|
||||
})
|
||||
} catch (e) {
|
||||
// 忽略错误,前端已标记
|
||||
}
|
||||
},
|
||||
async markAllRead () {
|
||||
this.notifications.forEach(n => { n.is_read = true })
|
||||
try {
|
||||
await request({
|
||||
url: '/api/strategies/notifications/read-all',
|
||||
method: 'post'
|
||||
})
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
},
|
||||
async clearNotifications () {
|
||||
this.notifications = []
|
||||
try {
|
||||
await request({
|
||||
url: '/api/strategies/notifications/clear',
|
||||
method: 'delete'
|
||||
})
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
this.visible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.notice-icon-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.header-notice {
|
||||
display: inline-block;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
span {
|
||||
vertical-align: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.notice-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notice-action {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background: #e6f7ff;
|
||||
|
||||
&:hover {
|
||||
background: #bae7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0f0f0;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.notice-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.notice-item-title {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.notice-item-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notice-item-time {
|
||||
font-size: 11px;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-empty {
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.notice-footer {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
a {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 详情弹窗内容 */
|
||||
.notice-detail {
|
||||
.notice-detail-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.notice-detail-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.type-label {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
.notice-detail-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.notice-detail-content {
|
||||
.message-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
|
||||
h2, h3, h4 {
|
||||
margin: 12px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 16px; }
|
||||
h4 { font-size: 14px; }
|
||||
|
||||
li {
|
||||
margin-left: 20px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// HTML 报告样式
|
||||
&.html-report {
|
||||
.message-body {
|
||||
max-height: 70vh;
|
||||
padding: 0;
|
||||
margin: -16px -24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-detail-extra {
|
||||
.extra-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.extra-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
.label {
|
||||
flex-shrink: 0;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&.decision {
|
||||
align-items: center;
|
||||
|
||||
.confidence {
|
||||
margin-left: 8px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-detail-actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.header-notice-wrapper {
|
||||
top: 50px !important;
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 详情弹窗样式 */
|
||||
.notice-detail-modal {
|
||||
.ant-modal-header {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
// HTML 报告模式
|
||||
&.html-report-modal {
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑主题支持 */
|
||||
body.dark,
|
||||
body.realdark,
|
||||
.ant-layout.dark,
|
||||
.ant-layout.realdark {
|
||||
.header-notice-wrapper {
|
||||
.ant-popover-inner {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.ant-popover-arrow {
|
||||
border-color: #1f1f1f;
|
||||
}
|
||||
|
||||
.notice-header {
|
||||
border-color: #303030;
|
||||
|
||||
.notice-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
&:hover {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background: rgba(24, 144, 255, 0.15);
|
||||
|
||||
&:hover {
|
||||
background: rgba(24, 144, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.notice-item-icon {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.notice-item-content {
|
||||
.notice-item-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.notice-item-desc {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.notice-item-time {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-footer {
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.ant-empty-description {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
/* 详情弹窗暗黑主题 */
|
||||
.notice-detail-modal {
|
||||
.ant-modal-content {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
|
||||
.ant-modal-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.ant-divider {
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.notice-detail {
|
||||
.notice-detail-meta {
|
||||
.notice-detail-type .type-label {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.notice-detail-time {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.notice-detail-content .message-body {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.notice-detail-extra {
|
||||
.extra-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.extra-item {
|
||||
.label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.confidence {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import NoticeIcon from './NoticeIcon'
|
||||
export default NoticeIcon
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div :class="[prefixCls]">
|
||||
<slot name="subtitle">
|
||||
<div :class="[`${prefixCls}-subtitle`]">{{ typeof subTitle === 'string' ? subTitle : subTitle() }}</div>
|
||||
</slot>
|
||||
<div class="number-info-value">
|
||||
<span>{{ total }}</span>
|
||||
<span class="sub-total">
|
||||
{{ subTotal }}
|
||||
<icon :type="`caret-${status}`" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Icon from 'ant-design-vue/es/icon'
|
||||
|
||||
export default {
|
||||
name: 'NumberInfo',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-number-info'
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
subTotal: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
subTitle: {
|
||||
type: [String, Function],
|
||||
default: ''
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: 'up'
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Icon
|
||||
},
|
||||
data () {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import "index";
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
import NumberInfo from './NumberInfo'
|
||||
|
||||
export default NumberInfo
|
||||
@@ -0,0 +1,55 @@
|
||||
@import '../index';
|
||||
|
||||
@numberInfo-prefix-cls: ~"@{ant-pro-prefix}-number-info";
|
||||
|
||||
.@{numberInfo-prefix-cls} {
|
||||
.ant-pro-number-info-subtitle {
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
font-size: @font-size-base;
|
||||
line-height: 22px;
|
||||
color: @text-color-secondary;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.number-info-value {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 0;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
|
||||
& > span {
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
margin-right: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
.sub-total {
|
||||
margin-right: 0;
|
||||
font-size: @font-size-lg;
|
||||
color: @text-color-secondary;
|
||||
vertical-align: top;
|
||||
|
||||
i {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
transform: scale(.82);
|
||||
}
|
||||
|
||||
.anticon-caret-up {
|
||||
color: @red-6;
|
||||
}
|
||||
|
||||
.anticon-caret-down {
|
||||
color: @green-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# NumberInfo 数据文本
|
||||
|
||||
常用在数据卡片中,用于突出展示某个业务数据。
|
||||
|
||||
|
||||
|
||||
引用方式:
|
||||
|
||||
```javascript
|
||||
import NumberInfo from '@/components/NumberInfo'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NumberInfo
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 代码演示 [demo](https://pro.loacg.com/test/home)
|
||||
|
||||
```html
|
||||
<number-info
|
||||
:sub-title="() => { return 'Visits this week' }"
|
||||
:total="12321"
|
||||
status="up"
|
||||
:sub-total="17.1"></number-info>
|
||||
```
|
||||
|
||||
|
||||
|
||||
## API
|
||||
|
||||
参数 | 说明 | 类型 | 默认值
|
||||
----|------|-----|------
|
||||
title | 标题 | ReactNode\|string | -
|
||||
subTitle | 子标题 | ReactNode\|string | -
|
||||
total | 总量 | ReactNode\|string | -
|
||||
subTotal | 子总量 | ReactNode\|string | -
|
||||
status | 增加状态 | 'up \| down' | -
|
||||
theme | 状态样式 | string | 'light'
|
||||
gap | 设置数字和描述之间的间距(像素)| number | 8
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
const googleAdsUrl = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'
|
||||
export default {
|
||||
props: {
|
||||
isMobile: Boolean
|
||||
},
|
||||
// watch: {
|
||||
// $route (e, t) {
|
||||
// const adId = '#adsbygoogle'
|
||||
// // if(isGitee) {
|
||||
// // adId = '#cf';
|
||||
// // }
|
||||
// if (e.path !== t.path && this.$el.querySelector(adId)) {
|
||||
// this.$el.innerHTML = ''
|
||||
// this.load()
|
||||
// }
|
||||
// this.adInterval && clearInterval(this.adInterval)
|
||||
// this.adInterval = setInterval(() => {
|
||||
// if (!this.$el.querySelector(adId)) {
|
||||
// this.$el.innerHTML = ''
|
||||
// this.load()
|
||||
// }
|
||||
// }, 20000)
|
||||
// }
|
||||
// },
|
||||
mounted () {
|
||||
// this.load()
|
||||
},
|
||||
methods: {
|
||||
load () {
|
||||
if (googleAdsUrl) {
|
||||
/* eslint-disable */
|
||||
let adsbygoogle = []
|
||||
const e = document.createElement('script')
|
||||
e.id = '_adsbygoogle_js'
|
||||
e.src = googleAdsUrl
|
||||
this.$el.appendChild(e)
|
||||
setTimeout(() => {
|
||||
(adsbygoogle = window.adsbygoogle || []).push({})
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
},
|
||||
render () {
|
||||
// return <ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-4801326429087140" data-ad-slot="6929057621" />
|
||||
return <div class="business-pro-ad"><a href="https://store.antdv.com/pro/" target="_blank">(推荐) 企业级商用版 Admin Pro 现已发售,采用 Vue3 + TS 欢迎购买。</a></div>;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.business-pro-ad {
|
||||
position: fixed;
|
||||
background: rgba(255,255,255,0.25);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
padding: 0 12px;
|
||||
height: 48px;
|
||||
width: 258px;
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Spin } from 'ant-design-vue'
|
||||
|
||||
export const PageLoading = {
|
||||
name: 'PageLoading',
|
||||
props: {
|
||||
tip: {
|
||||
type: String,
|
||||
default: 'Loading..'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'large'
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const style = {
|
||||
textAlign: 'center',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1100
|
||||
}
|
||||
const spinStyle = {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '40%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}
|
||||
return (<div style={style}>
|
||||
<Spin size={this.size} style={spinStyle} tip={this.tip} />
|
||||
</div>)
|
||||
}
|
||||
}
|
||||
|
||||
const version = '0.0.1'
|
||||
const loading = {}
|
||||
|
||||
loading.newInstance = (Vue, options) => {
|
||||
let loadingElement = document.querySelector('body>div[type=loading]')
|
||||
if (!loadingElement) {
|
||||
loadingElement = document.createElement('div')
|
||||
loadingElement.setAttribute('type', 'loading')
|
||||
loadingElement.setAttribute('class', 'ant-loading-wrapper')
|
||||
document.body.appendChild(loadingElement)
|
||||
}
|
||||
|
||||
const cdProps = Object.assign({ visible: false, size: 'large', tip: 'Loading...' }, options)
|
||||
|
||||
const instance = new Vue({
|
||||
data () {
|
||||
return {
|
||||
...cdProps
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const { tip } = this
|
||||
const props = {}
|
||||
this.tip && (props.tip = tip)
|
||||
if (this.visible) {
|
||||
return <PageLoading { ...{ props } } />
|
||||
}
|
||||
return null
|
||||
}
|
||||
}).$mount(loadingElement)
|
||||
|
||||
function update (config) {
|
||||
const { visible, size, tip } = { ...cdProps, ...config }
|
||||
instance.$set(instance, 'visible', visible)
|
||||
if (tip) {
|
||||
instance.$set(instance, 'tip', tip)
|
||||
}
|
||||
if (size) {
|
||||
instance.$set(instance, 'size', size)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
instance,
|
||||
update
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
show: function (options) {
|
||||
this.instance.update({ ...options, visible: true })
|
||||
},
|
||||
hide: function () {
|
||||
this.instance.update({ visible: false })
|
||||
}
|
||||
}
|
||||
|
||||
const install = function (Vue, options) {
|
||||
if (Vue.prototype.$loading) {
|
||||
return
|
||||
}
|
||||
api.instance = loading.newInstance(Vue, options)
|
||||
Vue.prototype.$loading = api
|
||||
}
|
||||
|
||||
export default {
|
||||
version,
|
||||
install
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Select } from 'ant-design-vue'
|
||||
import './index.less'
|
||||
|
||||
const GlobalSearch = {
|
||||
name: 'GlobalSearch',
|
||||
data () {
|
||||
return {
|
||||
visible: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
const keyboardHandle = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const { ctrlKey, shiftKey, altKey, keyCode } = e
|
||||
// key is `K` and hold ctrl
|
||||
if (keyCode === 75 && ctrlKey && !shiftKey && !altKey) {
|
||||
this.visible = !this.visible
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', keyboardHandle)
|
||||
},
|
||||
render () {
|
||||
const { visible } = this
|
||||
const handleSearch = (e) => {
|
||||
this.$emit('search', e)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
this.$emit('change', e)
|
||||
}
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div class={'global-search global-search-wrapper'}>
|
||||
<div class={'global-search-box'}>
|
||||
<Select
|
||||
size={'large'}
|
||||
showSearch
|
||||
placeholder="Input search text.."
|
||||
style={{ width: '100%' }}
|
||||
defaultActiveFirstOption={false}
|
||||
showArrow={false}
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
onChange={handleChange}
|
||||
notFoundContent={null}
|
||||
>
|
||||
</Select>
|
||||
<div class={'global-search-tips'}>Open with Ctrl/⌘ + K</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GlobalSearch.install = function (Vue) {
|
||||
Vue.component(GlobalSearch.name, GlobalSearch)
|
||||
}
|
||||
|
||||
export default GlobalSearch
|
||||
@@ -0,0 +1,25 @@
|
||||
@import '~ant-design-vue/es/style/themes/default';
|
||||
|
||||
.global-search-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: @zindex-modal-mask;
|
||||
background: @modal-mask-bg;
|
||||
|
||||
.global-search-box {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
width: 450px;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
.global-search-tips {
|
||||
font-size: @font-size-lg;
|
||||
color: @white;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import './index.less'
|
||||
|
||||
import { Icon, Menu, Dropdown } from 'ant-design-vue'
|
||||
import { i18nRender } from '@/locales'
|
||||
import i18nMixin from '@/store/i18n-mixin'
|
||||
|
||||
const locales = ['en-US', 'ja-JP', 'ko-KR', 'vi-VN', 'th-TH', 'ar-SA', 'fr-FR', 'de-DE', 'zh-TW', 'zh-CN']
|
||||
const languageLabels = {
|
||||
'zh-CN': '简体中文',
|
||||
'zh-TW': '繁體中文',
|
||||
'en-US': 'English',
|
||||
'ja-JP': '日本語',
|
||||
'ko-KR': '한국어',
|
||||
'vi-VN': 'Tiếng Việt',
|
||||
'th-TH': 'ไทย',
|
||||
'ar-SA': 'العربية',
|
||||
'fr-FR': 'Français',
|
||||
'de-DE': 'Deutsch'
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
const languageIcons = {
|
||||
'zh-CN': '🇨🇳',
|
||||
'zh-TW': 'sg',
|
||||
'en-US': '🇺🇸',
|
||||
'ja-JP': '🇯🇵',
|
||||
'ko-KR': '🇰🇷',
|
||||
'vi-VN': '🇻🇳',
|
||||
'th-TH': '🇹🇭',
|
||||
'ar-SA': '🇸🇦',
|
||||
'fr-FR': '🇫🇷',
|
||||
'de-DE': '🇩🇪'
|
||||
}
|
||||
|
||||
const SelectLang = {
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-drop-down'
|
||||
}
|
||||
},
|
||||
name: 'SelectLang',
|
||||
mixins: [i18nMixin],
|
||||
render () {
|
||||
const { prefixCls } = this
|
||||
const changeLang = ({ key }) => {
|
||||
this.setLang(key)
|
||||
}
|
||||
const langMenu = (
|
||||
<Menu class={['menu', 'ant-pro-header-menu']} selectedKeys={[this.currentLang]} onClick={changeLang}>
|
||||
{locales.map(locale => (
|
||||
<Menu.Item key={locale}>
|
||||
<span role="img" aria-label={languageLabels[locale]}>
|
||||
{languageIcons[locale]}
|
||||
</span>{' '}
|
||||
{languageLabels[locale]}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
)
|
||||
return (
|
||||
<Dropdown overlay={langMenu} placement="bottomRight">
|
||||
<span class={prefixCls}>
|
||||
<Icon type="global" title={i18nRender('navBar.lang')} />
|
||||
</span>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default SelectLang
|
||||
@@ -0,0 +1,30 @@
|
||||
@import '~ant-design-vue/es/style/themes/default';
|
||||
|
||||
@header-menu-prefix-cls: ~'@{ant-prefix}-pro-header-menu';
|
||||
@header-drop-down-prefix-cls: ~'@{ant-prefix}-pro-drop-down';
|
||||
|
||||
.@{header-menu-prefix-cls} {
|
||||
.anticon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.@{header-drop-down-prefix-cls} {
|
||||
line-height: @layout-header-height;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
|
||||
> i {
|
||||
font-size: 16px !important;
|
||||
transform: none !important;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="setting-drawer">
|
||||
<a-drawer
|
||||
width="300"
|
||||
placement="right"
|
||||
@close="onClose"
|
||||
:closable="true"
|
||||
:visible="visible"
|
||||
:get-container="false"
|
||||
>
|
||||
<div class="setting-drawer-index-content">
|
||||
|
||||
<div :style="{ marginBottom: '24px' }">
|
||||
<h3 class="setting-drawer-index-title">{{ $t('app.setting.pagestyle') }}</h3>
|
||||
|
||||
<div class="setting-drawer-index-blockChecbox">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
{{ $t('app.setting.pagestyle.dark') }}
|
||||
</template>
|
||||
<div class="setting-drawer-index-item" @click="handleMenuTheme('dark')">
|
||||
<img src="https://gw.alipayobjects.com/zos/rmsportal/LCkqqYNmvBEbokSDscrm.svg" alt="dark">
|
||||
<div class="setting-drawer-index-selectIcon" v-if="currentNavTheme === 'dark'">
|
||||
<a-icon type="check"/>
|
||||
</div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
{{ $t('app.setting.pagestyle.light') }}
|
||||
</template>
|
||||
<div class="setting-drawer-index-item" @click="handleMenuTheme('light')">
|
||||
<img src="https://gw.alipayobjects.com/zos/rmsportal/jpRkZQMyYRryryPNtyIC.svg" alt="light">
|
||||
<div class="setting-drawer-index-selectIcon" v-if="currentNavTheme !== 'dark'">
|
||||
<a-icon type="check"/>
|
||||
</div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :style="{ marginBottom: '24px' }">
|
||||
<h3 class="setting-drawer-index-title">{{ $t('app.setting.themecolor') }}</h3>
|
||||
|
||||
<div style="height: 20px">
|
||||
<a-tooltip class="setting-drawer-theme-color-colorBlock" v-for="(item, index) in colorList" :key="index">
|
||||
<template slot="title">
|
||||
{{ item.key }}
|
||||
</template>
|
||||
<a-tag :color="item.color" @click="changeColor(item.color)">
|
||||
<a-icon type="check" v-if="item.color === currentPrimaryColor"></a-icon>
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
|
||||
<!-- Navigation Mode and Content Width are hidden -->
|
||||
<div :style="{ marginBottom: '24px', display: 'none' }">
|
||||
<h3 class="setting-drawer-index-title">{{ $t('app.setting.navigationmode') }}</h3>
|
||||
|
||||
<div class="setting-drawer-index-blockChecbox">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
{{ $t('app.setting.sidemenu.nav') }}
|
||||
</template>
|
||||
<div class="setting-drawer-index-item" @click="handleLayout('sidemenu')">
|
||||
<img src="https://gw.alipayobjects.com/zos/rmsportal/JopDzEhOqwOjeNTXkoje.svg" alt="sidemenu">
|
||||
<div class="setting-drawer-index-selectIcon" v-if="layoutMode === 'sidemenu'">
|
||||
<a-icon type="check"/>
|
||||
</div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
{{ $t('app.setting.topmenu.nav') }}
|
||||
</template>
|
||||
<div class="setting-drawer-index-item" @click="handleLayout('topmenu')">
|
||||
<img src="https://gw.alipayobjects.com/zos/rmsportal/KDNDBbriJhLwuqMoxcAr.svg" alt="topmenu">
|
||||
<div class="setting-drawer-index-selectIcon" v-if="layoutMode !== 'sidemenu'">
|
||||
<a-icon type="check"/>
|
||||
</div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div :style="{ marginTop: '24px' }">
|
||||
<a-list :split="false">
|
||||
<a-list-item>
|
||||
<a-tooltip slot="actions">
|
||||
<template slot="title">
|
||||
{{ $t('app.setting.content-width.tooltip') }}
|
||||
</template>
|
||||
<a-select size="small" style="width: 80px;" :defaultValue="currentContentWidth" @change="handleContentWidthChange">
|
||||
<a-select-option value="Fixed">{{ $t('app.setting.content-width.fixed') }}</a-select-option>
|
||||
<a-select-option value="Fluid" v-if="layoutMode !== 'sidemenu'">{{ $t('app.setting.content-width.fluid') }}</a-select-option>
|
||||
</a-select>
|
||||
</a-tooltip>
|
||||
<a-list-item-meta>
|
||||
<div slot="title">{{ $t('app.setting.content-width') }}</div>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
<a-list-item>
|
||||
<a-switch slot="actions" size="small" :defaultChecked="currentFixedHeader" @change="handleFixedHeader" />
|
||||
<a-list-item-meta>
|
||||
<div slot="title">{{ $t('app.setting.fixedheader') }}</div>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
<a-list-item>
|
||||
<a-switch slot="actions" size="small" :disabled="!currentFixedHeader" :defaultChecked="currentAutoHideHeader" @change="handleFixedHeaderHidden" />
|
||||
<a-list-item-meta>
|
||||
<a-tooltip slot="title" placement="left">
|
||||
<template slot="title">{{ $t('app.setting.fixedheader.tooltip') }}</template>
|
||||
<div :style="{ opacity: !currentFixedHeader ? '0.5' : '1' }">{{ $t('app.setting.autoHideHeader') }}</div>
|
||||
</a-tooltip>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
<a-list-item >
|
||||
<a-switch slot="actions" size="small" :disabled="(layoutMode === 'topmenu')" :defaultChecked="fixSiderbar" @change="handleFixSiderbar" />
|
||||
<a-list-item-meta>
|
||||
<div slot="title" :style="{ textDecoration: layoutMode === 'topmenu' ? 'line-through' : 'unset' }">{{ $t('app.setting.fixedsidebar') }}</div>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
|
||||
<div :style="{ marginBottom: '24px' }">
|
||||
<h3 class="setting-drawer-index-title">{{ $t('app.setting.othersettings') }}</h3>
|
||||
<div>
|
||||
<a-list :split="false">
|
||||
<a-list-item>
|
||||
<a-switch slot="actions" size="small" :defaultChecked="currentColorWeak" @change="onColorWeak" />
|
||||
<a-list-item-meta>
|
||||
<div slot="title">{{ $t('app.setting.weakmode') }}</div>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
<a-list-item>
|
||||
<a-switch slot="actions" size="small" :defaultChecked="currentMultiTab" @change="onMultiTab" />
|
||||
<a-list-item-meta>
|
||||
<div slot="title">{{ $t('app.setting.multitab') }}</div>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
<!-- <div :style="{ marginBottom: '24px' }">
|
||||
<a-button
|
||||
@click="doCopy"
|
||||
icon="copy"
|
||||
block
|
||||
>拷贝设置</a-button>
|
||||
</div> -->
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SettingItem from './SettingItem'
|
||||
import config from '@/config/defaultSettings'
|
||||
import { updateTheme, updateColorWeak, getColorList } from './settingConfig'
|
||||
import { baseMixin } from '@/store/app-mixin'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingItem
|
||||
},
|
||||
props: {
|
||||
settings: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
mixins: [baseMixin],
|
||||
data () {
|
||||
return {
|
||||
visible: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
colorList () {
|
||||
return getColorList()
|
||||
},
|
||||
layoutMode () {
|
||||
return this.settings.layout || this.layout || 'sidemenu'
|
||||
},
|
||||
fixSiderbar () {
|
||||
return this.settings.fixSiderbar !== undefined ? this.settings.fixSiderbar : (this.fixedSidebar || false)
|
||||
},
|
||||
currentNavTheme () {
|
||||
return this.settings.theme || this.navTheme || 'light'
|
||||
},
|
||||
currentPrimaryColor () {
|
||||
return this.settings.primaryColor || this.primaryColor || '#1890FF'
|
||||
},
|
||||
currentFixedHeader () {
|
||||
return this.settings.fixedHeader !== undefined ? this.settings.fixedHeader : (this.fixedHeader || false)
|
||||
},
|
||||
currentContentWidth () {
|
||||
return this.settings.contentWidth || this.contentWidth || 'Fluid'
|
||||
},
|
||||
currentAutoHideHeader () {
|
||||
return this.settings.autoHideHeader !== undefined ? this.settings.autoHideHeader : (this.autoHideHeader || false)
|
||||
},
|
||||
currentColorWeak () {
|
||||
return this.settings.colorWeak !== undefined ? this.settings.colorWeak : (this.colorWeak || false)
|
||||
},
|
||||
currentMultiTab () {
|
||||
return this.settings.multiTab !== undefined ? this.settings.multiTab : (this.multiTab || false)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
},
|
||||
mounted () {
|
||||
// 初始化时静默更新主题色,不显示消息
|
||||
updateTheme(this.currentPrimaryColor, true)
|
||||
if (this.currentColorWeak !== config.colorWeak) {
|
||||
updateColorWeak(this.currentColorWeak)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showDrawer () {
|
||||
this.visible = true
|
||||
},
|
||||
onClose () {
|
||||
this.visible = false
|
||||
},
|
||||
toggle () {
|
||||
this.visible = !this.visible
|
||||
},
|
||||
onColorWeak (checked) {
|
||||
this.$emit('change', { type: 'colorWeak', value: checked })
|
||||
updateColorWeak(checked)
|
||||
},
|
||||
onMultiTab (checked) {
|
||||
this.$emit('change', { type: 'multiTab', value: checked })
|
||||
},
|
||||
handleMenuTheme (theme) {
|
||||
this.$emit('change', { type: 'theme', value: theme })
|
||||
},
|
||||
doCopy () {
|
||||
// get current settings from mixin or this.$store.state.app, pay attention to the property name
|
||||
const text = `export default {
|
||||
primaryColor: '${this.currentPrimaryColor}', // primary color of ant design
|
||||
navTheme: '${this.currentNavTheme}', // theme for nav menu
|
||||
layout: '${this.layoutMode}', // nav menu position: sidemenu or topmenu
|
||||
contentWidth: '${this.currentContentWidth}', // layout of content: Fluid or Fixed, only works when layout is topmenu
|
||||
fixedHeader: ${this.currentFixedHeader}, // sticky header
|
||||
fixSiderbar: ${this.fixSiderbar}, // sticky siderbar
|
||||
autoHideHeader: ${this.currentAutoHideHeader}, // auto hide header
|
||||
colorWeak: ${this.currentColorWeak},
|
||||
multiTab: ${this.currentMultiTab},
|
||||
production: process.env.NODE_ENV === 'production' && process.env.VUE_APP_PREVIEW !== 'true'
|
||||
}`
|
||||
this.$copyText(text).then(message => {
|
||||
this.$message.success(this.$t('app.setting.copy.success'))
|
||||
}).catch(() => {
|
||||
this.$message.error(this.$t('app.setting.copy.fail'))
|
||||
})
|
||||
},
|
||||
handleLayout (mode) {
|
||||
this.$emit('change', { type: 'layout', value: mode })
|
||||
// 因为顶部菜单不能固定左侧菜单栏,所以强制关闭
|
||||
if (mode === 'topmenu') {
|
||||
this.$emit('change', { type: 'fixSiderbar', value: false })
|
||||
}
|
||||
},
|
||||
handleContentWidthChange (type) {
|
||||
this.$emit('change', { type: 'contentWidth', value: type })
|
||||
},
|
||||
changeColor (color) {
|
||||
if (this.currentPrimaryColor !== color) {
|
||||
this.$emit('change', { type: 'primaryColor', value: color })
|
||||
updateTheme(color)
|
||||
}
|
||||
},
|
||||
handleFixedHeader (fixed) {
|
||||
this.$emit('change', { type: 'fixedHeader', value: fixed })
|
||||
},
|
||||
handleFixedHeaderHidden (autoHidden) {
|
||||
this.$emit('change', { type: 'autoHideHeader', value: autoHidden })
|
||||
},
|
||||
handleFixSiderbar (fixed) {
|
||||
if (this.layoutMode === 'topmenu') {
|
||||
this.$emit('change', { type: 'fixSiderbar', value: false })
|
||||
return
|
||||
}
|
||||
this.$emit('change', { type: 'fixSiderbar', value: fixed })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 隐藏所有可能的悬浮按钮 */
|
||||
:deep(.ant-drawer-handle),
|
||||
:deep(.setting-drawer-index-handle) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.setting-drawer-index-content {
|
||||
|
||||
.setting-drawer-index-blockChecbox {
|
||||
display: flex;
|
||||
|
||||
.setting-drawer-index-item {
|
||||
margin-right: 16px;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.setting-drawer-index-selectIcon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding-top: 15px;
|
||||
padding-left: 24px;
|
||||
height: 100%;
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
.setting-drawer-theme-color-colorBlock {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
float: left;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="setting-drawer-index-item">
|
||||
<h3 class="setting-drawer-index-title">{{ title }}</h3>
|
||||
<slot></slot>
|
||||
<a-divider v-if="divider"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SettingItem',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
divider: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
||||
.setting-drawer-index-item {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.setting-drawer-index-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, .85);
|
||||
line-height: 22px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import SettingDrawer from './SettingDrawer'
|
||||
export default SettingDrawer
|
||||
@@ -0,0 +1,53 @@
|
||||
import message from 'ant-design-vue/es/message'
|
||||
// import defaultSettings from '../defaultSettings';
|
||||
import themeColor from './themeColor.js'
|
||||
import i18n from '@/locales'
|
||||
|
||||
// let lessNodesAppended
|
||||
const getColorList = () => {
|
||||
return [
|
||||
{
|
||||
key: i18n.t('app.setting.themecolor.dust'), color: '#F5222D'
|
||||
},
|
||||
{
|
||||
key: i18n.t('app.setting.themecolor.volcano'), color: '#FA541C'
|
||||
},
|
||||
{
|
||||
key: i18n.t('app.setting.themecolor.sunset'), color: '#FAAD14'
|
||||
},
|
||||
{
|
||||
key: i18n.t('app.setting.themecolor.cyan'), color: '#13C2C2'
|
||||
},
|
||||
{
|
||||
key: i18n.t('app.setting.themecolor.green'), color: '#52C41A'
|
||||
},
|
||||
{
|
||||
key: i18n.t('app.setting.themecolor.daybreak'), color: '#1890FF'
|
||||
},
|
||||
{
|
||||
key: i18n.t('app.setting.themecolor.geekblue'), color: '#2F54EB'
|
||||
},
|
||||
{
|
||||
key: i18n.t('app.setting.themecolor.purple'), color: '#722ED1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const updateTheme = (newPrimaryColor, silent = false) => {
|
||||
const hideMessage = silent ? null : message.loading(i18n.t('app.setting.theme.switching'), 0)
|
||||
themeColor.changeColor(newPrimaryColor).finally(() => {
|
||||
if (hideMessage) {
|
||||
setTimeout(() => {
|
||||
hideMessage()
|
||||
}, 10)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateColorWeak = colorWeak => {
|
||||
// document.body.className = colorWeak ? 'colorWeak' : '';
|
||||
const app = document.body.querySelector('#app')
|
||||
colorWeak ? app.classList.add('colorWeak') : app.classList.remove('colorWeak')
|
||||
}
|
||||
|
||||
export { updateTheme, getColorList, updateColorWeak }
|
||||
@@ -0,0 +1,36 @@
|
||||
import client from 'webpack-theme-color-replacer/client'
|
||||
import generate from '@ant-design/colors/lib/generate'
|
||||
|
||||
export default {
|
||||
getAntdSerials (color) {
|
||||
// 淡化(即less的tint)
|
||||
const lightens = new Array(9).fill().map((t, i) => {
|
||||
return client.varyColor.lighten(color, i / 10)
|
||||
})
|
||||
// colorPalette变换得到颜色值
|
||||
const colorPalettes = generate(color)
|
||||
const rgb = client.varyColor.toNum3(color.replace('#', '')).join(',')
|
||||
return lightens.concat(colorPalettes).concat(rgb)
|
||||
},
|
||||
changeColor (newColor) {
|
||||
// Check if client.changer is available (plugin might not be loaded in some build configs)
|
||||
if (!client || !client.changer || typeof client.changer.changeColor !== 'function') {
|
||||
// Return a resolved promise to avoid breaking the calling code
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
var options = {
|
||||
newColors: this.getAntdSerials(newColor), // new colors array, one-to-one corresponde with `matchColors`
|
||||
changeUrl (cssUrl) {
|
||||
return `/${cssUrl}` // while router is not `hash` mode, it needs absolute path
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return client.changer.changeColor(options, Promise)
|
||||
} catch (error) {
|
||||
// Return a resolved promise to avoid breaking the calling code
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div :class="[prefixCls, lastCls, blockCls, gridCls]">
|
||||
<div v-if="title" class="antd-pro-components-standard-form-row-index-label">
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
<div class="antd-pro-components-standard-form-row-index-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const classes = [
|
||||
'antd-pro-components-standard-form-row-index-standardFormRowBlock',
|
||||
'antd-pro-components-standard-form-row-index-standardFormRowGrid',
|
||||
'antd-pro-components-standard-form-row-index-standardFormRowLast'
|
||||
]
|
||||
export default {
|
||||
name: 'StandardFormRow',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'antd-pro-components-standard-form-row-index-standardFormRow'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
last: {
|
||||
type: Boolean
|
||||
},
|
||||
block: {
|
||||
type: Boolean
|
||||
},
|
||||
grid: {
|
||||
type: Boolean
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lastCls () {
|
||||
return this.last ? classes[2] : null
|
||||
},
|
||||
blockCls () {
|
||||
return this.block ? classes[0] : null
|
||||
},
|
||||
gridCls () {
|
||||
return this.grid ? classes[1] : null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '../index.less';
|
||||
|
||||
.antd-pro-components-standard-form-row-index-standardFormRow {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed @border-color-split;
|
||||
|
||||
:deep(.ant-form-item ) {
|
||||
margin-right: 24px;
|
||||
}
|
||||
:deep(.ant-form-item-label label) {
|
||||
margin-right: 0;
|
||||
color: @text-color;
|
||||
}
|
||||
:deep(.ant-form-item-label,
|
||||
.ant-form-item-control) {
|
||||
padding: 0;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.antd-pro-components-standard-form-row-index-label {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 24px;
|
||||
color: @heading-color;
|
||||
font-size: @font-size-base;
|
||||
text-align: right;
|
||||
& > span {
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
&::after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.antd-pro-components-standard-form-row-index-content {
|
||||
flex: 1 1 0;
|
||||
:deep(.ant-form-item:last-child) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.antd-pro-components-standard-form-row-index-standardFormRowLast {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.antd-pro-components-standard-form-row-index-standardFormRowBlock {
|
||||
:deep(.ant-form-item,
|
||||
div.ant-form-item-control-wrapper) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&.antd-pro-components-standard-form-row-index-standardFormRowGrid {
|
||||
:deep(.ant-form-item,
|
||||
div.ant-form-item-control-wrapper) {
|
||||
display: block;
|
||||
}
|
||||
:deep(.ant-form-item-label) {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
import StandardFormRow from './StandardFormRow'
|
||||
|
||||
export default StandardFormRow
|
||||
@@ -0,0 +1,341 @@
|
||||
Table 重封装组件说明
|
||||
====
|
||||
|
||||
|
||||
封装说明
|
||||
----
|
||||
|
||||
> 基础的使用方式与 API 与 [官方版(Table)](https://vuecomponent.github.io/ant-design-vue/components/table-cn/) 本一致,在其基础上,封装了加载数据的方法。
|
||||
>
|
||||
> 你无需在你是用表格的页面进行分页逻辑处理,仅需向 Table 组件传递绑定 `:data="Promise"` 对象即可
|
||||
|
||||
该 `table` 由 [@Saraka](https://github.com/saraka-tsukai) 完成封装
|
||||
|
||||
|
||||
例子1
|
||||
----
|
||||
(基础使用)
|
||||
|
||||
```vue
|
||||
|
||||
<template>
|
||||
<s-table
|
||||
ref="table"
|
||||
size="default"
|
||||
:rowKey="(record) => record.data.id"
|
||||
:columns="columns"
|
||||
:data="loadData"
|
||||
:rowSelection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }"
|
||||
>
|
||||
</s-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import STable from '@/components'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
STable
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
columns: [
|
||||
{
|
||||
title: '规则编号',
|
||||
dataIndex: 'no'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description'
|
||||
},
|
||||
{
|
||||
title: '服务调用次数',
|
||||
dataIndex: 'callNo',
|
||||
sorter: true,
|
||||
needTotal: true,
|
||||
customRender: (text) => text + ' 次'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
needTotal: true
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
sorter: true
|
||||
}
|
||||
],
|
||||
// 查询条件参数
|
||||
queryParam: {},
|
||||
// 加载数据方法 必须为 Promise 对象
|
||||
loadData: parameter => {
|
||||
return this.$http.get('/service', {
|
||||
params: Object.assign(parameter, this.queryParam)
|
||||
}).then(res => {
|
||||
return res.result
|
||||
})
|
||||
},
|
||||
selectedRowKeys: [],
|
||||
selectedRows: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSelectChange (selectedRowKeys, selectedRows) {
|
||||
this.selectedRowKeys = selectedRowKeys
|
||||
this.selectedRows = selectedRows
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
例子2
|
||||
----
|
||||
|
||||
(简单的表格,最后一列是各种操作)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<s-table
|
||||
ref="table"
|
||||
size="default"
|
||||
:columns="columns"
|
||||
:data="loadData"
|
||||
>
|
||||
<span slot="action" slot-scope="text, record">
|
||||
<a>编辑</a>
|
||||
<a-divider type="vertical"/>
|
||||
<a-dropdown>
|
||||
<a class="ant-dropdown-link">
|
||||
更多 <a-icon type="down"/>
|
||||
</a>
|
||||
<a-menu slot="overlay">
|
||||
<a-menu-item>
|
||||
<a href="javascript:;">1st menu item</a>
|
||||
</a-menu-item>
|
||||
<a-menu-item>
|
||||
<a href="javascript:;">2nd menu item</a>
|
||||
</a-menu-item>
|
||||
<a-menu-item>
|
||||
<a href="javascript:;">3rd menu item</a>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</span>
|
||||
</s-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import STable from '@/components/table/'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
STable
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
columns: [
|
||||
{
|
||||
title: '规则编号',
|
||||
dataIndex: 'no'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description'
|
||||
},
|
||||
{
|
||||
title: '服务调用次数',
|
||||
dataIndex: 'callNo',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
},
|
||||
{
|
||||
table: '操作',
|
||||
dataIndex: 'action',
|
||||
scopedSlots: {customRender: 'action'},
|
||||
}
|
||||
],
|
||||
// 查询条件参数
|
||||
queryParam: {},
|
||||
// 加载数据方法 必须为 Promise 对象
|
||||
loadData: parameter => {
|
||||
return this.$http.get('/service', {
|
||||
params: Object.assign(parameter, this.queryParam)
|
||||
}).then(res => {
|
||||
return res.result
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
edit(row) {
|
||||
// axios 发送数据到后端 修改数据成功后
|
||||
// 调用 refresh() 重新加载列表数据
|
||||
// 这里 setTimeout 模拟发起请求的网络延迟..
|
||||
setTimeout(() => {
|
||||
this.$refs.table.refresh() // refresh() 不传参默认值 false 不刷新到分页第一页
|
||||
}, 1500)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
|
||||
|
||||
内置方法
|
||||
----
|
||||
|
||||
通过 `this.$refs.table` 调用
|
||||
|
||||
`this.$refs.table.refresh(true)` 刷新列表 (用户新增/修改数据后,重载列表数据)
|
||||
|
||||
> 注意:要调用 `refresh(bool)` 需要给表格组件设定 `ref` 值
|
||||
>
|
||||
> `refresh()` 方法可以传一个 `bool` 值,当有传值 或值为 `true` 时,则刷新时会强制刷新到第一页(常用户页面 搜索 按钮进行搜索时,结果从第一页开始分页)
|
||||
|
||||
|
||||
内置属性
|
||||
----
|
||||
> 除去 `a-table` 自带属性外,还而外提供了一些额外属性属性
|
||||
|
||||
|
||||
| 属性 | 说明 | 类型 | 默认值 |
|
||||
| -------------- | ----------------------------------------------- | ----------------- | ------ |
|
||||
| alert | 设置是否显示表格信息栏 | [object, boolean] | null |
|
||||
| showPagination | 显示分页选择器,可传 'auto' \| boolean | [string, boolean] | 'auto' |
|
||||
| data | 加载数据方法 必须为 `Promise` 对象 **必须绑定** | Promise | - |
|
||||
|
||||
|
||||
`alert` 属性对象:
|
||||
|
||||
```javascript
|
||||
alert: {
|
||||
show: Boolean,
|
||||
clear: [Function, Boolean]
|
||||
}
|
||||
```
|
||||
|
||||
注意事项
|
||||
----
|
||||
|
||||
> 你可能需要为了与后端提供的接口返回结果一致而去修改以下代码:
|
||||
> (需要注意的是,这里的修改是全局性的,意味着整个项目所有使用该 table 组件都需要遵守这个返回结果定义的字段。)
|
||||
>
|
||||
> 文档中的结构有可能由于组件 bug 进行修正而改动。实际修改请以当时最新版本为准
|
||||
|
||||
修改 `@/components/table/index.js` 第 156 行起
|
||||
|
||||
|
||||
|
||||
```javascript
|
||||
result.then(r => {
|
||||
this.localPagination = this.showPagination && Object.assign({}, this.localPagination, {
|
||||
current: r.pageNo, // 返回结果中的当前分页数
|
||||
total: r.totalCount, // 返回结果中的总记录数
|
||||
showSizeChanger: this.showSizeChanger,
|
||||
pageSize: (pagination && pagination.pageSize) ||
|
||||
this.localPagination.pageSize
|
||||
}) || false
|
||||
// 为防止删除数据后导致页面当前页面数据长度为 0 ,自动翻页到上一页
|
||||
if (r.data.length === 0 && this.showPagination && this.localPagination.current > 1) {
|
||||
this.localPagination.current--
|
||||
this.loadData()
|
||||
return
|
||||
}
|
||||
|
||||
// 这里用于判断接口是否有返回 r.totalCount 且 this.showPagination = true 且 pageNo 和 pageSize 存在 且 totalCount 小于等于 pageNo * pageSize 的大小
|
||||
// 当情况满足时,表示数据不满足分页大小,关闭 table 分页功能
|
||||
try {
|
||||
if ((['auto', true].includes(this.showPagination) && r.totalCount <= (r.pageNo * this.localPagination.pageSize))) {
|
||||
this.localPagination.hideOnSinglePage = true
|
||||
}
|
||||
} catch (e) {
|
||||
this.localPagination = false
|
||||
}
|
||||
console.log('loadData -> this.localPagination', this.localPagination)
|
||||
this.localDataSource = r.data // 返回结果中的数组数据
|
||||
this.localLoading = false
|
||||
})
|
||||
```
|
||||
返回 JSON 例子:
|
||||
```json
|
||||
{
|
||||
"message": "",
|
||||
"result": {
|
||||
"data": [{
|
||||
id: 1,
|
||||
cover: 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png',
|
||||
title: 'Alipay',
|
||||
description: '那是一种内在的东西, 他们到达不了,也无法触及的',
|
||||
status: 1,
|
||||
updatedAt: '2018-07-26 00:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cover: 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png',
|
||||
title: 'Angular',
|
||||
description: '希望是一个好东西,也许是最好的,好东西是不会消亡的',
|
||||
status: 1,
|
||||
updatedAt: '2018-07-26 00:00:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cover: 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png',
|
||||
title: 'Ant Design',
|
||||
description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆',
|
||||
status: 1,
|
||||
updatedAt: '2018-07-26 00:00:00'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
cover: 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png',
|
||||
title: 'Ant Design Pro',
|
||||
description: '那时候我只会想自己想要什么,从不想自己拥有什么',
|
||||
status: 1,
|
||||
updatedAt: '2018-07-26 00:00:00'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
cover: 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png',
|
||||
title: 'Bootstrap',
|
||||
description: '凛冬将至',
|
||||
status: 1,
|
||||
updatedAt: '2018-07-26 00:00:00'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
cover: 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png',
|
||||
title: 'Vue',
|
||||
description: '生命就像一盒巧克力,结果往往出人意料',
|
||||
status: 1,
|
||||
updatedAt: '2018-07-26 00:00:00'
|
||||
}
|
||||
],
|
||||
"pageSize": 10,
|
||||
"pageNo": 0,
|
||||
"totalPage": 6,
|
||||
"totalCount": 57
|
||||
},
|
||||
"status": 200,
|
||||
"timestamp": 1534955098193
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
更新时间
|
||||
----
|
||||
|
||||
该文档最后更新于: 2019-06-23 PM 17:19
|
||||
@@ -0,0 +1,325 @@
|
||||
import T from 'ant-design-vue/es/table/Table'
|
||||
import get from 'lodash.get'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
needTotalList: [],
|
||||
|
||||
selectedRows: [],
|
||||
selectedRowKeys: [],
|
||||
|
||||
localLoading: false,
|
||||
localDataSource: [],
|
||||
localPagination: Object.assign({}, this.pagination),
|
||||
|
||||
// 存储表格onchange时的filters, sorter对象
|
||||
filters: {},
|
||||
sorter: {}
|
||||
}
|
||||
},
|
||||
props: Object.assign({}, T.props, {
|
||||
rowKey: {
|
||||
type: [String, Function],
|
||||
default: 'key'
|
||||
},
|
||||
data: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
pageNum: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
showSizeChanger: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
/**
|
||||
* alert: {
|
||||
* show: true,
|
||||
* clear: Function
|
||||
* }
|
||||
*/
|
||||
alert: {
|
||||
type: [Object, Boolean],
|
||||
default: null
|
||||
},
|
||||
rowSelection: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
/** @Deprecated */
|
||||
showAlertInfo: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showPagination: {
|
||||
type: String | Boolean,
|
||||
default: 'auto'
|
||||
},
|
||||
/**
|
||||
* enable page URI mode
|
||||
*
|
||||
* e.g:
|
||||
* /users/1
|
||||
* /users/2
|
||||
* /users/3?queryParam=test
|
||||
* ...
|
||||
*/
|
||||
pageURI: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}),
|
||||
watch: {
|
||||
'localPagination.current' (val) {
|
||||
this.pageURI && this.$router.push({
|
||||
...this.$route,
|
||||
name: this.$route.name,
|
||||
params: Object.assign({}, this.$route.params, {
|
||||
pageNo: val
|
||||
})
|
||||
})
|
||||
// change pagination, reset total data
|
||||
this.needTotalList = this.initTotalList(this.columns)
|
||||
this.selectedRowKeys = []
|
||||
this.selectedRows = []
|
||||
},
|
||||
pageNum (val) {
|
||||
Object.assign(this.localPagination, {
|
||||
current: val
|
||||
})
|
||||
},
|
||||
pageSize (val) {
|
||||
Object.assign(this.localPagination, {
|
||||
pageSize: val
|
||||
})
|
||||
},
|
||||
showSizeChanger (val) {
|
||||
Object.assign(this.localPagination, {
|
||||
showSizeChanger: val
|
||||
})
|
||||
}
|
||||
},
|
||||
created () {
|
||||
const { pageNo } = this.$route.params
|
||||
const localPageNum = this.pageURI && (pageNo && parseInt(pageNo)) || this.pageNum
|
||||
this.localPagination = ['auto', true].includes(this.showPagination) && Object.assign({}, this.localPagination, {
|
||||
current: localPageNum,
|
||||
pageSize: this.pageSize,
|
||||
showSizeChanger: this.showSizeChanger
|
||||
}) || false
|
||||
this.needTotalList = this.initTotalList(this.columns)
|
||||
this.loadData()
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 表格重新加载方法
|
||||
* 如果参数为 true, 则强制刷新到第一页
|
||||
* @param Boolean bool
|
||||
*/
|
||||
refresh (bool = false) {
|
||||
bool && (this.localPagination = Object.assign({}, {
|
||||
current: 1, pageSize: this.pageSize
|
||||
}))
|
||||
this.loadData()
|
||||
},
|
||||
/**
|
||||
* 加载数据方法
|
||||
* @param {Object} pagination 分页选项器
|
||||
* @param {Object} filters 过滤条件
|
||||
* @param {Object} sorter 排序条件
|
||||
*/
|
||||
loadData (pagination, filters = this.filters, sorter = this.sorter) {
|
||||
this.filters = filters
|
||||
this.sorter = sorter
|
||||
|
||||
this.localLoading = true
|
||||
const parameter = Object.assign({
|
||||
pageNo: (pagination && pagination.current) ||
|
||||
this.showPagination && this.localPagination.current || this.pageNum,
|
||||
pageSize: (pagination && pagination.pageSize) ||
|
||||
this.showPagination && this.localPagination.pageSize || this.pageSize
|
||||
},
|
||||
(sorter && sorter.field && {
|
||||
sortField: sorter.field
|
||||
}) || {},
|
||||
(sorter && sorter.order && {
|
||||
sortOrder: sorter.order
|
||||
}) || {}, {
|
||||
...filters
|
||||
}
|
||||
)
|
||||
const result = this.data(parameter)
|
||||
// 对接自己的通用数据接口需要修改下方代码中的 r.pageNo, r.totalCount, r.data
|
||||
// eslint-disable-next-line
|
||||
if ((typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function') {
|
||||
result.then(r => {
|
||||
this.localPagination = this.showPagination && Object.assign({}, this.localPagination, {
|
||||
current: r.pageNo, // 返回结果中的当前分页数
|
||||
total: r.totalCount, // 返回结果中的总记录数
|
||||
showSizeChanger: this.showSizeChanger,
|
||||
pageSize: (pagination && pagination.pageSize) ||
|
||||
this.localPagination.pageSize
|
||||
}) || false
|
||||
// 为防止删除数据后导致页面当前页面数据长度为 0 ,自动翻页到上一页
|
||||
if (r.data.length === 0 && this.showPagination && this.localPagination.current > 1) {
|
||||
this.localPagination.current--
|
||||
this.loadData()
|
||||
return
|
||||
}
|
||||
|
||||
// 这里用于判断接口是否有返回 r.totalCount 且 this.showPagination = true 且 pageNo 和 pageSize 存在 且 totalCount 小于等于 pageNo * pageSize 的大小
|
||||
// 当情况满足时,表示数据不满足分页大小,关闭 table 分页功能
|
||||
try {
|
||||
if ((['auto', true].includes(this.showPagination) && r.totalCount <= (r.pageNo * this.localPagination.pageSize))) {
|
||||
this.localPagination.hideOnSinglePage = true
|
||||
}
|
||||
} catch (e) {
|
||||
this.localPagination = false
|
||||
}
|
||||
this.localDataSource = r.data // 返回结果中的数组数据
|
||||
})
|
||||
.finally(() => {
|
||||
this.localLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
initTotalList (columns) {
|
||||
const totalList = []
|
||||
columns && columns instanceof Array && columns.forEach(column => {
|
||||
if (column.needTotal) {
|
||||
totalList.push({
|
||||
...column,
|
||||
total: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
return totalList
|
||||
},
|
||||
/**
|
||||
* 用于更新已选中的列表数据 total 统计
|
||||
* @param selectedRowKeys
|
||||
* @param selectedRows
|
||||
*/
|
||||
updateSelect (selectedRowKeys, selectedRows) {
|
||||
this.selectedRows = selectedRows
|
||||
this.selectedRowKeys = selectedRowKeys
|
||||
const list = this.needTotalList
|
||||
this.needTotalList = list.map(item => {
|
||||
return {
|
||||
...item,
|
||||
total: selectedRows.reduce((sum, val) => {
|
||||
const total = sum + parseInt(get(val, item.dataIndex))
|
||||
return isNaN(total) ? 0 : total
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 清空 table 已选中项
|
||||
*/
|
||||
clearSelected () {
|
||||
if (this.rowSelection) {
|
||||
this.rowSelection.onChange([], [])
|
||||
this.updateSelect([], [])
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 处理交给 table 使用者去处理 clear 事件时,内部选中统计同时调用
|
||||
* @param callback
|
||||
* @returns {*}
|
||||
*/
|
||||
renderClear (callback) {
|
||||
if (this.selectedRowKeys.length <= 0) return null
|
||||
return (
|
||||
<a style="margin-left: 24px" onClick={() => {
|
||||
callback()
|
||||
this.clearSelected()
|
||||
}}>清空</a>
|
||||
)
|
||||
},
|
||||
renderAlert () {
|
||||
// 绘制统计列数据
|
||||
const needTotalItems = this.needTotalList.map((item) => {
|
||||
return (<span style="margin-right: 12px">
|
||||
{item.title}总计 <a style="font-weight: 600">{!item.customRender ? item.total : item.customRender(item.total)}</a>
|
||||
</span>)
|
||||
})
|
||||
|
||||
// 绘制 清空 按钮
|
||||
const clearItem = (typeof this.alert.clear === 'boolean' && this.alert.clear) ? (
|
||||
this.renderClear(this.clearSelected)
|
||||
) : (this.alert !== null && typeof this.alert.clear === 'function') ? (
|
||||
this.renderClear(this.alert.clear)
|
||||
) : null
|
||||
|
||||
// 绘制 alert 组件
|
||||
return (
|
||||
<a-alert showIcon={true} style="margin-bottom: 16px">
|
||||
<template slot="message">
|
||||
<span style="margin-right: 12px">已选择: <a style="font-weight: 600">{this.selectedRows.length}</a></span>
|
||||
{needTotalItems}
|
||||
{clearItem}
|
||||
</template>
|
||||
</a-alert>
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
render () {
|
||||
const props = {}
|
||||
const localKeys = Object.keys(this.$data)
|
||||
const showAlert = (typeof this.alert === 'object' && this.alert !== null && this.alert.show) && typeof this.rowSelection.selectedRowKeys !== 'undefined' || this.alert
|
||||
|
||||
Object.keys(T.props).forEach(k => {
|
||||
const localKey = `local${k.substring(0, 1).toUpperCase()}${k.substring(1)}`
|
||||
if (localKeys.includes(localKey)) {
|
||||
props[k] = this[localKey]
|
||||
return props[k]
|
||||
}
|
||||
if (k === 'rowSelection') {
|
||||
if (showAlert && this.rowSelection) {
|
||||
// 如果需要使用alert,则重新绑定 rowSelection 事件
|
||||
props[k] = {
|
||||
...this.rowSelection,
|
||||
selectedRows: this.selectedRows,
|
||||
selectedRowKeys: this.selectedRowKeys,
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
this.updateSelect(selectedRowKeys, selectedRows)
|
||||
typeof this[k].onChange !== 'undefined' && this[k].onChange(selectedRowKeys, selectedRows)
|
||||
}
|
||||
}
|
||||
return props[k]
|
||||
} else if (!this.rowSelection) {
|
||||
// 如果没打算开启 rowSelection 则清空默认的选择项
|
||||
props[k] = null
|
||||
return props[k]
|
||||
}
|
||||
}
|
||||
this[k] && (props[k] = this[k])
|
||||
return props[k]
|
||||
})
|
||||
const table = (
|
||||
<a-table {...{ props, scopedSlots: { ...this.$scopedSlots } }} onChange={this.loadData} onExpand={ (expanded, record) => { this.$emit('expand', expanded, record) } }>
|
||||
{ Object.keys(this.$slots).map(name => (<template slot={name}>{this.$slots[name]}</template>)) }
|
||||
</a-table>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="table-wrapper">
|
||||
{ showAlert ? this.renderAlert() : null }
|
||||
{ table }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Tag } from 'ant-design-vue'
|
||||
const { CheckableTag } = Tag
|
||||
|
||||
export default {
|
||||
name: 'TagSelectOption',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-tag-select-option'
|
||||
},
|
||||
value: {
|
||||
type: [String, Number, Object],
|
||||
default: ''
|
||||
},
|
||||
checked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
localChecked: this.checked || false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'checked' (val) {
|
||||
this.localChecked = val
|
||||
},
|
||||
'$parent.items': {
|
||||
handler: function (val) {
|
||||
this.value && val.hasOwnProperty(this.value) && (this.localChecked = val[this.value])
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const { $slots, value } = this
|
||||
const onChange = (checked) => {
|
||||
this.$emit('change', { value, checked })
|
||||
}
|
||||
return (<CheckableTag key={value} vModel={this.localChecked} onChange={onChange}>
|
||||
{$slots.default}
|
||||
</CheckableTag>)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import PropTypes from 'ant-design-vue/es/_util/vue-types'
|
||||
import Option from './TagSelectOption.jsx'
|
||||
import { filterEmpty } from '@/components/_util/util'
|
||||
|
||||
export default {
|
||||
Option,
|
||||
name: 'TagSelect',
|
||||
model: {
|
||||
prop: 'checked',
|
||||
event: 'change'
|
||||
},
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-tag-select'
|
||||
},
|
||||
defaultValue: {
|
||||
type: PropTypes.array,
|
||||
default: null
|
||||
},
|
||||
value: {
|
||||
type: PropTypes.array,
|
||||
default: null
|
||||
},
|
||||
expandable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hideCheckAll: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
localCheckAll: false,
|
||||
items: this.getItemsKey(filterEmpty(this.$slots.default)),
|
||||
val: this.value || this.defaultValue || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onChange (checked) {
|
||||
const key = Object.keys(this.items).filter(key => key === checked.value)
|
||||
this.items[key] = checked.checked
|
||||
const bool = Object.values(this.items).lastIndexOf(false)
|
||||
if (bool === -1) {
|
||||
this.localCheckAll = true
|
||||
} else {
|
||||
this.localCheckAll = false
|
||||
}
|
||||
},
|
||||
onCheckAll (checked) {
|
||||
Object.keys(this.items).forEach(v => {
|
||||
this.items[v] = checked.checked
|
||||
})
|
||||
this.localCheckAll = checked.checked
|
||||
},
|
||||
getItemsKey (items) {
|
||||
const totalItem = {}
|
||||
items.forEach(item => {
|
||||
totalItem[item.componentOptions.propsData && item.componentOptions.propsData.value] = false
|
||||
})
|
||||
return totalItem
|
||||
},
|
||||
// CheckAll Button
|
||||
renderCheckAll () {
|
||||
const props = {
|
||||
on: {
|
||||
change: (checked) => {
|
||||
this.onCheckAll(checked)
|
||||
checked.value = 'total'
|
||||
this.$emit('change', checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
const checkAllElement = <Option key={'total'} checked={this.localCheckAll} {...props}>All</Option>
|
||||
return !this.hideCheckAll && checkAllElement || null
|
||||
},
|
||||
// expandable
|
||||
renderExpandable () {
|
||||
|
||||
},
|
||||
// render option
|
||||
renderTags (items) {
|
||||
const listeners = {
|
||||
change: (checked) => {
|
||||
this.onChange(checked)
|
||||
this.$emit('change', checked)
|
||||
}
|
||||
}
|
||||
|
||||
return items.map(vnode => {
|
||||
const options = vnode.componentOptions
|
||||
options.listeners = listeners
|
||||
return vnode
|
||||
})
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const { $props: { prefixCls } } = this
|
||||
const classString = {
|
||||
[`${prefixCls}`]: true
|
||||
}
|
||||
const tagItems = filterEmpty(this.$slots.default)
|
||||
return (
|
||||
<div class={classString}>
|
||||
{this.renderCheckAll()}
|
||||
{this.renderTags(tagItems)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import './style.less'
|
||||
import { getStrFullLength, cutStrByFullLength } from '../_util/util'
|
||||
import Input from 'ant-design-vue/es/input'
|
||||
const TextArea = Input.TextArea
|
||||
|
||||
export default {
|
||||
name: 'LimitTextArea',
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'change'
|
||||
},
|
||||
props: Object.assign({}, TextArea.props, {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-textarea-limit'
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
value: {
|
||||
type: String
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 200
|
||||
}
|
||||
}),
|
||||
data () {
|
||||
return {
|
||||
currentLimit: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value (val) {
|
||||
this.calcLimitNum(val)
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.calcLimitNum(this.value)
|
||||
},
|
||||
methods: {
|
||||
handleChange (e) {
|
||||
const value = e.target.value
|
||||
const len = getStrFullLength(value)
|
||||
if (len <= this.limit) {
|
||||
this.currentLimit = len
|
||||
this.$emit('change', value)
|
||||
} else {
|
||||
const str = cutStrByFullLength(value, this.limit)
|
||||
this.currentLimit = getStrFullLength(str)
|
||||
this.$emit('change', str)
|
||||
}
|
||||
},
|
||||
calcLimitNum (val) {
|
||||
const len = getStrFullLength(val)
|
||||
this.currentLimit = len
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const { prefixCls, ...props } = this.$props
|
||||
return (
|
||||
<div class={this.prefixCls}>
|
||||
<TextArea {...{ props }} value={this.value} onChange={this.handleChange}>
|
||||
</TextArea>
|
||||
<span class="limit">{this.currentLimit}/{this.limit}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.ant-textarea-limit {
|
||||
position: relative;
|
||||
|
||||
.limit {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 5px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Menu, Icon, Input } from 'ant-design-vue'
|
||||
|
||||
const { Item, ItemGroup, SubMenu } = Menu
|
||||
const { Search } = Input
|
||||
|
||||
export default {
|
||||
name: 'Tree',
|
||||
props: {
|
||||
dataSource: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
openKeys: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
search: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.localOpenKeys = this.openKeys.slice(0)
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
localOpenKeys: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handlePlus (item) {
|
||||
this.$emit('add', item)
|
||||
},
|
||||
handleTitleClick (...args) {
|
||||
this.$emit('titleClick', { args })
|
||||
},
|
||||
|
||||
renderSearch () {
|
||||
return (
|
||||
<Search
|
||||
placeholder="input search text"
|
||||
style="width: 100%; margin-bottom: 1rem"
|
||||
/>
|
||||
)
|
||||
},
|
||||
renderIcon (icon) {
|
||||
return icon && (<Icon type={icon} />) || null
|
||||
},
|
||||
renderMenuItem (item) {
|
||||
return (
|
||||
<Item key={item.key}>
|
||||
{ this.renderIcon(item.icon) }
|
||||
{ item.title }
|
||||
<a class="btn" style="width: 20px;z-index:1300" {...{ on: { click: () => this.handlePlus(item) } }}><a-icon type="plus"/></a>
|
||||
</Item>
|
||||
)
|
||||
},
|
||||
renderItem (item) {
|
||||
return item.children ? this.renderSubItem(item, item.key) : this.renderMenuItem(item, item.key)
|
||||
},
|
||||
renderItemGroup (item) {
|
||||
const childrenItems = item.children.map(o => {
|
||||
return this.renderItem(o, o.key)
|
||||
})
|
||||
|
||||
return (
|
||||
<ItemGroup key={item.key}>
|
||||
<template slot="title">
|
||||
<span>{ item.title }</span>
|
||||
<a-dropdown>
|
||||
<a class="btn"><a-icon type="ellipsis" /></a>
|
||||
<a-menu slot="overlay">
|
||||
<a-menu-item key="1">新增</a-menu-item>
|
||||
<a-menu-item key="2">合并</a-menu-item>
|
||||
<a-menu-item key="3">移除</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
{ childrenItems }
|
||||
</ItemGroup>
|
||||
)
|
||||
},
|
||||
renderSubItem (item, key) {
|
||||
const childrenItems = item.children && item.children.map(o => {
|
||||
return this.renderItem(o, o.key)
|
||||
})
|
||||
|
||||
const title = (
|
||||
<span slot="title">
|
||||
{ this.renderIcon(item.icon) }
|
||||
<span>{ item.title }</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
if (item.group) {
|
||||
return this.renderItemGroup(item)
|
||||
}
|
||||
// titleClick={this.handleTitleClick(item)}
|
||||
return (
|
||||
<SubMenu key={key}>
|
||||
{ title }
|
||||
{ childrenItems }
|
||||
</SubMenu>
|
||||
)
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const { dataSource, search } = this.$props
|
||||
|
||||
// this.localOpenKeys = openKeys.slice(0)
|
||||
const list = dataSource.map(item => {
|
||||
return this.renderItem(item)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="tree-wrapper">
|
||||
{ search ? this.renderSearch() : null }
|
||||
<Menu mode="inline" class="custom-tree" {...{ on: { click: item => this.$emit('click', item), 'update:openKeys': val => { this.localOpenKeys = val } } }} openKeys={this.localOpenKeys}>
|
||||
{ list }
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div :class="[prefixCls, reverseColor && 'reverse-color' ]">
|
||||
<span>
|
||||
<slot name="term"></slot>
|
||||
<span class="item-text">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</span>
|
||||
<span :class="[flag]"><a-icon :type="`caret-${flag}`"/></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Trend',
|
||||
props: {
|
||||
prefixCls: {
|
||||
type: String,
|
||||
default: 'ant-pro-trend'
|
||||
},
|
||||
/**
|
||||
* 上升下降标识:up|down
|
||||
*/
|
||||
flag: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/**
|
||||
* 颜色反转
|
||||
*/
|
||||
reverseColor: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import "index";
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
import Trend from './Trend.vue'
|
||||
|
||||
export default Trend
|
||||
@@ -0,0 +1,44 @@
|
||||
@import '../index';
|
||||
|
||||
@trend-prefix-cls: ~"@{ant-pro-prefix}-trend";
|
||||
|
||||
.@{trend-prefix-cls} {
|
||||
display: inline-block;
|
||||
font-size: @font-size-base;
|
||||
line-height: 22px;
|
||||
|
||||
.up,
|
||||
.down {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
margin-left: 4px;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
transform: scale(.83);
|
||||
}
|
||||
}
|
||||
|
||||
.item-text {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
color: rgb(0 0 0 / 85%);
|
||||
}
|
||||
|
||||
.up {
|
||||
color: @red-6;
|
||||
}
|
||||
|
||||
.down {
|
||||
top: -1px;
|
||||
color: @green-6;
|
||||
}
|
||||
|
||||
&.reverse-color .up {
|
||||
color: @green-6;
|
||||
}
|
||||
|
||||
&.reverse-color .down {
|
||||
color: @red-6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Trend 趋势标记
|
||||
|
||||
趋势符号,标记上升和下降趋势。通常用绿色代表“好”,红色代表“不好”,股票涨跌场景除外。
|
||||
|
||||
|
||||
|
||||
引用方式:
|
||||
|
||||
```javascript
|
||||
import Trend from '@/components/Trend'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Trend
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 代码演示 [demo](https://pro.loacg.com/test/home)
|
||||
|
||||
```html
|
||||
<trend flag="up">5%</trend>
|
||||
```
|
||||
或
|
||||
```html
|
||||
<trend flag="up">
|
||||
<span slot="term">工资</span>
|
||||
5%
|
||||
</trend>
|
||||
```
|
||||
或
|
||||
```html
|
||||
<trend flag="up" term="工资">5%</trend>
|
||||
```
|
||||
|
||||
|
||||
## API
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
|----------|------------------------------------------|-------------|-------|
|
||||
| flag | 上升下降标识:`up|down` | string | - |
|
||||
| reverseColor | 颜色反转 | Boolean | false |
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="turnstile-container" v-if="enabled">
|
||||
<div ref="turnstileRef" :id="containerId"></div>
|
||||
<div v-if="error" class="turnstile-error">
|
||||
{{ error }}
|
||||
<a @click="reset">{{ $t('user.security.retry') || 'Retry' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
let turnstileScriptLoaded = false
|
||||
let turnstileScriptLoading = false
|
||||
const turnstileCallbacks = []
|
||||
|
||||
function loadTurnstileScript () {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (turnstileScriptLoaded) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
turnstileCallbacks.push({ resolve, reject })
|
||||
|
||||
if (turnstileScriptLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
turnstileScriptLoading = true
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
script.async = true
|
||||
script.defer = true
|
||||
|
||||
script.onload = () => {
|
||||
turnstileScriptLoaded = true
|
||||
turnstileCallbacks.forEach(cb => cb.resolve())
|
||||
turnstileCallbacks.length = 0
|
||||
}
|
||||
|
||||
script.onerror = () => {
|
||||
turnstileScriptLoading = false
|
||||
turnstileCallbacks.forEach(cb => cb.reject(new Error('Failed to load Turnstile script')))
|
||||
turnstileCallbacks.length = 0
|
||||
}
|
||||
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Turnstile',
|
||||
|
||||
props: {
|
||||
siteKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'auto' // 'light', 'dark', 'auto'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal' // 'normal', 'compact'
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
widgetId: null,
|
||||
token: null,
|
||||
error: null,
|
||||
containerId: `turnstile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
if (this.enabled && this.siteKey) {
|
||||
this.initTurnstile()
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
this.cleanup()
|
||||
},
|
||||
|
||||
watch: {
|
||||
siteKey (newVal) {
|
||||
if (newVal && this.enabled) {
|
||||
this.initTurnstile()
|
||||
}
|
||||
},
|
||||
enabled (newVal) {
|
||||
if (newVal && this.siteKey) {
|
||||
this.initTurnstile()
|
||||
} else {
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initTurnstile () {
|
||||
try {
|
||||
await loadTurnstileScript()
|
||||
this.renderWidget()
|
||||
} catch (e) {
|
||||
this.error = 'Failed to load verification'
|
||||
console.error('Turnstile init error:', e)
|
||||
}
|
||||
},
|
||||
|
||||
renderWidget () {
|
||||
if (!window.turnstile || !this.$refs.turnstileRef) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up existing widget
|
||||
this.cleanup()
|
||||
|
||||
this.widgetId = window.turnstile.render(this.$refs.turnstileRef, {
|
||||
sitekey: this.siteKey,
|
||||
theme: this.theme,
|
||||
size: this.size,
|
||||
callback: (token) => {
|
||||
this.token = token
|
||||
this.error = null
|
||||
this.$emit('success', token)
|
||||
},
|
||||
'error-callback': () => {
|
||||
this.token = null
|
||||
this.error = 'Verification failed'
|
||||
this.$emit('error')
|
||||
},
|
||||
'expired-callback': () => {
|
||||
this.token = null
|
||||
this.$emit('expired')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
reset () {
|
||||
this.token = null
|
||||
this.error = null
|
||||
if (window.turnstile && this.widgetId !== null) {
|
||||
window.turnstile.reset(this.widgetId)
|
||||
} else {
|
||||
this.renderWidget()
|
||||
}
|
||||
},
|
||||
|
||||
getToken () {
|
||||
return this.token
|
||||
},
|
||||
|
||||
cleanup () {
|
||||
if (window.turnstile && this.widgetId !== null) {
|
||||
try {
|
||||
window.turnstile.remove(this.widgetId)
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
this.widgetId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.turnstile-container {
|
||||
margin: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.turnstile-error {
|
||||
margin-top: 8px;
|
||||
color: #ff4d4f;
|
||||
font-size: 13px;
|
||||
|
||||
a {
|
||||
margin-left: 8px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* components util
|
||||
*/
|
||||
|
||||
/**
|
||||
* 清理空值,对象
|
||||
* @param children
|
||||
* @returns {*[]}
|
||||
*/
|
||||
export function filterEmpty (children = []) {
|
||||
return children.filter(c => c.tag || (c.text && c.text.trim() !== ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字符串长度,英文字符 长度1,中文字符长度2
|
||||
* @param {*} str
|
||||
*/
|
||||
export const getStrFullLength = (str = '') =>
|
||||
str.split('').reduce((pre, cur) => {
|
||||
const charCode = cur.charCodeAt(0)
|
||||
if (charCode >= 0 && charCode <= 128) {
|
||||
return pre + 1
|
||||
}
|
||||
return pre + 2
|
||||
}, 0)
|
||||
|
||||
/**
|
||||
* 截取字符串,根据 maxLength 截取后返回
|
||||
* @param {*} str
|
||||
* @param {*} maxLength
|
||||
*/
|
||||
export const cutStrByFullLength = (str = '', maxLength) => {
|
||||
let showLength = 0
|
||||
return str.split('').reduce((pre, cur) => {
|
||||
const charCode = cur.charCodeAt(0)
|
||||
if (charCode >= 0 && charCode <= 128) {
|
||||
showLength += 1
|
||||
} else {
|
||||
showLength += 2
|
||||
}
|
||||
if (showLength <= maxLength) {
|
||||
return pre + cur
|
||||
}
|
||||
return pre
|
||||
}, '')
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// chart
|
||||
import Bar from '@/components/Charts/Bar'
|
||||
import ChartCard from '@/components/Charts/ChartCard'
|
||||
import Liquid from '@/components/Charts/Liquid'
|
||||
import MiniArea from '@/components/Charts/MiniArea'
|
||||
import MiniSmoothArea from '@/components/Charts/MiniSmoothArea'
|
||||
import MiniBar from '@/components/Charts/MiniBar'
|
||||
import MiniProgress from '@/components/Charts/MiniProgress'
|
||||
import Radar from '@/components/Charts/Radar'
|
||||
import RankList from '@/components/Charts/RankList'
|
||||
import TransferBar from '@/components/Charts/TransferBar'
|
||||
import TagCloud from '@/components/Charts/TagCloud'
|
||||
|
||||
// pro components
|
||||
import AvatarList from '@/components/AvatarList'
|
||||
import Ellipsis from '@/components/Ellipsis'
|
||||
import FooterToolbar from '@/components/FooterToolbar'
|
||||
import NumberInfo from '@/components/NumberInfo'
|
||||
import Tree from '@/components/Tree/Tree'
|
||||
import Trend from '@/components/Trend'
|
||||
import STable from '@/components/Table'
|
||||
import MultiTab from '@/components/MultiTab'
|
||||
import IconSelector from '@/components/IconSelector'
|
||||
import TagSelect from '@/components/TagSelect'
|
||||
import StandardFormRow from '@/components/StandardFormRow'
|
||||
import ArticleListContent from '@/components/ArticleListContent'
|
||||
|
||||
import Dialog from '@/components/Dialog'
|
||||
|
||||
export {
|
||||
AvatarList,
|
||||
Bar,
|
||||
ChartCard,
|
||||
Liquid,
|
||||
MiniArea,
|
||||
MiniSmoothArea,
|
||||
MiniBar,
|
||||
MiniProgress,
|
||||
Radar,
|
||||
TagCloud,
|
||||
RankList,
|
||||
TransferBar,
|
||||
Trend,
|
||||
Ellipsis,
|
||||
FooterToolbar,
|
||||
NumberInfo,
|
||||
Tree,
|
||||
STable,
|
||||
MultiTab,
|
||||
IconSelector,
|
||||
TagSelect,
|
||||
StandardFormRow,
|
||||
ArticleListContent,
|
||||
|
||||
Dialog
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import '~ant-design-vue/lib/style/index';
|
||||
|
||||
// The prefix to use on all css classes from ant-pro.
|
||||
@ant-pro-prefix : ant-pro;
|
||||
@ant-global-sider-zindex : 106;
|
||||
@ant-global-header-zindex : 105;
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<!-- 两步验证 -->
|
||||
<a-modal
|
||||
centered
|
||||
:visible="localVisible"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="localVisible = $event"
|
||||
:maskClosable="false"
|
||||
>
|
||||
<div slot="title" :style="{ textAlign: 'center' }">两步验证</div>
|
||||
<template slot="footer">
|
||||
<div :style="{ textAlign: 'center' }">
|
||||
<a-button key="back" @click="handleCancel">返回</a-button>
|
||||
<a-button key="submit" type="primary" :loading="stepLoading" @click="handleStepOk">
|
||||
继续
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-spin :spinning="stepLoading">
|
||||
<a-form layout="vertical" :auto-form-create="(form)=>{this.form = form}">
|
||||
<div class="step-form-wrapper">
|
||||
<p style="text-align: center" v-if="!stepLoading">请在手机中打开 Google Authenticator 或两步验证 APP<br />输入 6 位动态码</p>
|
||||
<p style="text-align: center" v-else>正在验证..<br/>请稍后</p>
|
||||
<a-form-item
|
||||
:style="{ textAlign: 'center' }"
|
||||
hasFeedback
|
||||
fieldDecoratorId="stepCode"
|
||||
:fieldDecoratorOptions="{rules: [{ required: true, message: '请输入 6 位动态码!', pattern: /^\d{6}$/, len: 6 }]}"
|
||||
>
|
||||
<a-input :style="{ textAlign: 'center' }" @keyup.enter.native="handleStepOk" placeholder="000000" />
|
||||
</a-form-item>
|
||||
<p style="text-align: center">
|
||||
<a @click="onForgeStepCode">遗失手机?</a>
|
||||
</p>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
stepLoading: false,
|
||||
form: null,
|
||||
localVisible: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible: {
|
||||
immediate: true,
|
||||
handler (val) {
|
||||
this.localVisible = val
|
||||
}
|
||||
},
|
||||
localVisible (val) {
|
||||
if (!val) {
|
||||
this.$emit('cancel')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleStepOk () {
|
||||
const vm = this
|
||||
this.stepLoading = true
|
||||
this.form.validateFields((err, values) => {
|
||||
if (!err) {
|
||||
setTimeout(() => {
|
||||
vm.stepLoading = false
|
||||
vm.$emit('success', { values })
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
this.stepLoading = false
|
||||
this.$emit('error', { err })
|
||||
})
|
||||
},
|
||||
handleCancel () {
|
||||
this.localVisible = false
|
||||
this.$emit('cancel')
|
||||
},
|
||||
onForgeStepCode () {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.step-form-wrapper {
|
||||
margin: 0 auto;
|
||||
width: 80%;
|
||||
max-width: 400px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user